From 1c70aab2b2174065f3b84b483a4da35bd94530cc Mon Sep 17 00:00:00 2001 From: Cris Barreiro Date: Wed, 10 Jul 2024 19:36:30 +0200 Subject: [PATCH] Add Duck Player settings --- .../app/browser/WebViewRequestInterceptor.kt | 8 ++ .../com/duckduckgo/app/pixels/AppPixelName.kt | 1 + .../app/settings/SettingsActivity.kt | 32 +++++ .../app/settings/SettingsViewModel.kt | 10 ++ .../res/layout/content_settings_settings.xml | 7 + .../app/settings/SettingsViewModelTest.kt | 29 +++++ .../impl/DuckPlayerSettingsViewModelTest.kt | 64 +++++++++ duckplayer/duckplayer-api/build.gradle | 2 + .../duckduckgo/duckplayer/api/DuckPlayer.kt | 27 ++++ .../api/DuckPlayerSettingsScreens.kt | 24 ++++ duckplayer/duckplayer-impl/build.gradle | 9 ++ .../src/main/AndroidManifest.xml | 26 ++++ .../duckplayer/impl/DuckPlayerDataStore.kt | 12 ++ .../impl/DuckPlayerFeatureRepository.kt | 20 ++- .../duckplayer/impl/DuckPlayerSettings.kt | 36 ++++++ .../impl/DuckPlayerSettingsActivity.kt | 122 ++++++++++++++++++ .../impl/DuckPlayerSettingsViewModel.kt | 70 ++++++++++ .../duckplayer/impl/RealDuckPlayer.kt | 24 ++++ .../src/main/res/drawable/clean_tube_128.xml | 20 +++ .../layout/activity_duck_player_settings.xml | 84 ++++++++++++ .../src/main/res/values/donottranslate.xml | 28 ++++ .../settings/api/ProSettingsPlugin.kt | 5 + .../settings/impl/DuckPlayerSettingModule.kt | 27 ++++ 23 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsViewModelTest.kt create mode 100644 duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerSettingsScreens.kt create mode 100644 duckplayer/duckplayer-impl/src/main/AndroidManifest.xml create mode 100644 duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettings.kt create mode 100644 duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsActivity.kt create mode 100644 duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsViewModel.kt create mode 100644 duckplayer/duckplayer-impl/src/main/res/drawable/clean_tube_128.xml create mode 100644 duckplayer/duckplayer-impl/src/main/res/layout/activity_duck_player_settings.xml create mode 100644 duckplayer/duckplayer-impl/src/main/res/values/donottranslate.xml create mode 100644 settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/DuckPlayerSettingModule.kt diff --git a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt index cb27c12a2b14..a015c682f816 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/WebViewRequestInterceptor.kt @@ -36,6 +36,7 @@ import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.isHttp import com.duckduckgo.duckplayer.api.DUCK_PLAYER_ASSETS_PATH import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.duckduckgo.httpsupgrade.api.HttpsUpgrader import com.duckduckgo.privacy.config.api.Gpc import com.duckduckgo.request.filterer.api.RequestFilterer @@ -123,6 +124,13 @@ class WebViewRequestInterceptor( return WebResourceResponse(null, null, null) } + if (url != null && duckPlayer.isYoutubeWatchUrl(url) && duckPlayer.getUserPreferences().privatePlayerMode == Enabled) { + withContext(dispatchers.main()) { + webView.loadUrl(duckPlayer.createDuckPlayerUriFromYoutube(url)) + } + return WebResourceResponse(null, null, null) + } + if (url != null && duckPlayer.isYoutubeNoCookie(url)) { val path = duckPlayer.getDuckPlayerAssetsPath(request.url) val mimeType = mimeTypeMap.getMimeTypeFromExtension(path?.substringAfterLast(".")) diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index 9b8b29b6d1d7..aab6dab83f15 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -117,6 +117,7 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { SETTINGS_WEB_TRACKING_PROTECTION_PRESSED("ms_web_tracking_protection_setting_pressed"), SETTINGS_ACCESSIBILITY_PRESSED("ms_accessibility_setting_pressed"), SETTINGS_ABOUT_PRESSED("ms_about_setting_pressed"), + SETTINGS_DUCK_PLAYER_PRESSED("ms_duck_player_setting_pressed"), SETTINGS_SYNC_PRESSED("ms_sync_pressed"), SETTINGS_PERMISSIONS_PRESSED("ms_permissions_setting_pressed"), SETTINGS_APPEARANCE_PRESSED("ms_appearance_setting_pressed"), diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt index 6de18c260bfb..4a69071e16a1 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt @@ -58,11 +58,13 @@ import com.duckduckgo.common.ui.view.show import com.duckduckgo.common.ui.viewbinding.viewBinding import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams import com.duckduckgo.internal.features.api.InternalFeaturePlugin import com.duckduckgo.macos.api.MacOsScreenWithEmptyParams import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerActivityWithEmptyParams import com.duckduckgo.mobile.android.app.tracking.ui.AppTrackingProtectionScreens.AppTrackerOnboardingActivityWithEmptyParamsParams import com.duckduckgo.navigation.api.GlobalActivityStarter +import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin import com.duckduckgo.settings.api.ProSettingsPlugin import com.duckduckgo.sync.api.SyncActivityWithEmptyParams import com.duckduckgo.windows.api.ui.WindowsScreenWithEmptyParams @@ -100,6 +102,12 @@ class SettingsActivity : DuckDuckGoActivity() { _proSettingsPlugin.getPlugins() } + @Inject + lateinit var _duckPlayerSettingsPlugin: PluginPoint + private val duckPlayerSettingsPlugin by lazy { + _duckPlayerSettingsPlugin.getPlugins() + } + private val viewsPrivacy get() = binding.includeSettings.contentSettingsPrivacy @@ -151,6 +159,7 @@ class SettingsActivity : DuckDuckGoActivity() { appearanceSetting.setClickListener { viewModel.onAppearanceSettingClicked() } accessibilitySetting.setClickListener { viewModel.onAccessibilitySettingClicked() } aboutSetting.setClickListener { viewModel.onAboutSettingClicked() } + settingsSectionDuckPlayer.setOnClickListener { viewModel.onDuckPlayerSettingsClicked() } } with(viewsMore) { @@ -167,6 +176,14 @@ class SettingsActivity : DuckDuckGoActivity() { viewsPro.addView(plugin.getView(this)) } } + + if (duckPlayerSettingsPlugin.isEmpty()) { + viewsSettings.settingsSectionDuckPlayer.gone() + } else { + duckPlayerSettingsPlugin.forEach { plugin -> + viewsSettings.settingsSectionDuckPlayer.addView(plugin.getView(this)) + } + } } private fun configureInternalFeatures() { @@ -198,6 +215,7 @@ class SettingsActivity : DuckDuckGoActivity() { updateSyncSetting(visible = it.showSyncSetting) updateAutoconsent(it.isAutoconsentEnabled) updatePrivacyPro(it.isPrivacyProEnabled) + updateDuckPlayer(it.isDuckPlayerEnabled) } }.launchIn(lifecycleScope) @@ -216,6 +234,14 @@ class SettingsActivity : DuckDuckGoActivity() { } } + private fun updateDuckPlayer(isDuckPlayerEnabled: Boolean) { + if (isDuckPlayerEnabled) { + viewsSettings.settingsSectionDuckPlayer.show() + } else { + viewsSettings.settingsSectionDuckPlayer.gone() + } + } + private fun updateAutofill(autofillEnabled: Boolean) = with(viewsSettings.autofillLoginsSetting) { visibility = if (autofillEnabled) { View.VISIBLE @@ -269,6 +295,7 @@ class SettingsActivity : DuckDuckGoActivity() { is Command.LaunchFireButtonScreen -> launchFireButtonScreen() is Command.LaunchPermissionsScreen -> launchPermissionsScreen() is Command.LaunchAppearanceScreen -> launchAppearanceScreen() + is Command.LaunchDuckPlayerSettings -> launchDuckPlayerSettings() is Command.LaunchAboutScreen -> launchAboutScreen() null -> TODO() } @@ -396,6 +423,11 @@ class SettingsActivity : DuckDuckGoActivity() { globalActivityStarter.start(this, AppearanceScreenNoParams, options) } + private fun launchDuckPlayerSettings() { + val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() + globalActivityStarter.start(this, DuckPlayerSettingsNoParams, options) + } + private fun launchAboutScreen() { val options = ActivityOptions.makeSceneTransitionAnimation(this).toBundle() globalActivityStarter.start(this, AboutScreenNoParams, options) diff --git a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt index d91ba41f4547..fb224ac5ee28 100644 --- a/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt @@ -32,6 +32,7 @@ import com.duckduckgo.autofill.api.email.EmailManager import com.duckduckgo.common.utils.ConflatedJob import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayer import com.duckduckgo.mobile.android.app.tracking.AppTrackingProtection import com.duckduckgo.subscriptions.api.Subscriptions import com.duckduckgo.sync.api.DeviceSyncState @@ -58,6 +59,7 @@ class SettingsViewModel @Inject constructor( private val dispatcherProvider: DispatcherProvider, private val autoconsent: Autoconsent, private val subscriptions: Subscriptions, + private val duckPlayer: DuckPlayer, ) : ViewModel(), DefaultLifecycleObserver { data class ViewState( @@ -70,6 +72,7 @@ class SettingsViewModel @Inject constructor( val showSyncSetting: Boolean = false, val isAutoconsentEnabled: Boolean = false, val isPrivacyProEnabled: Boolean = false, + val isDuckPlayerEnabled: Boolean = false, ) sealed class Command { @@ -90,6 +93,7 @@ class SettingsViewModel @Inject constructor( data object LaunchFireButtonScreen : Command() data object LaunchPermissionsScreen : Command() data object LaunchAppearanceScreen : Command() + data object LaunchDuckPlayerSettings : Command() data object LaunchAboutScreen : Command() } @@ -129,6 +133,7 @@ class SettingsViewModel @Inject constructor( showSyncSetting = deviceSyncState.isFeatureEnabled(), isAutoconsentEnabled = autoconsent.isSettingEnabled(), isPrivacyProEnabled = subscriptions.isEligible(), + isDuckPlayerEnabled = duckPlayer.isDuckPlayerAvailable(), ), ) } @@ -203,6 +208,11 @@ class SettingsViewModel @Inject constructor( pixel.fire(SETTINGS_ABOUT_PRESSED) } + fun onDuckPlayerSettingsClicked() { + viewModelScope.launch { command.send(Command.LaunchDuckPlayerSettings) } + pixel.fire(SETTINGS_DUCK_PLAYER_PRESSED) + } + fun onEmailProtectionSettingClicked() { viewModelScope.launch { val command = if (emailManager.isEmailFeatureSupported()) { diff --git a/app/src/main/res/layout/content_settings_settings.xml b/app/src/main/res/layout/content_settings_settings.xml index 69351e1ded65..eb871fd02484 100644 --- a/app/src/main/res/layout/content_settings_settings.xml +++ b/app/src/main/res/layout/content_settings_settings.xml @@ -73,6 +73,13 @@ android:layout_height="wrap_content" app:primaryText="@string/settingsAccessibility" /> + + + + /** * Sets the user preferences. * @@ -58,6 +69,14 @@ interface DuckPlayer { */ fun createDuckPlayerUriFromYoutubeNoCookie(uri: Uri): String + /** + * Creates a DuckPlayer URI from a YouTube URI. + * + * @param uri The YouTube URI. + * @return The DuckPlayer URI. + */ + fun createDuckPlayerUriFromYoutube(uri: Uri): String + /** * Creates a YouTube no-cookie URI from a DuckPlayer URI. * @@ -90,6 +109,14 @@ interface DuckPlayer { */ fun isYoutubeNoCookie(uri: Uri): Boolean + /** + * Checks if a URI is a YouTube no-cookie URI. + * + * @param uri The URI to check. + * @return True if the URI is a YouTube no-cookie URI, false otherwise. + */ + fun isYoutubeWatchUrl(uri: Uri): Boolean + /** * Checks if a string is a YouTube no-cookie URI. * diff --git a/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerSettingsScreens.kt b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerSettingsScreens.kt new file mode 100644 index 000000000000..886fcdb528c4 --- /dev/null +++ b/duckplayer/duckplayer-api/src/main/java/com/duckduckgo/duckplayer/api/DuckPlayerSettingsScreens.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.api + +import com.duckduckgo.navigation.api.GlobalActivityStarter + +/** + * Use this model to launch the Duck Player Settings screen + */ +object DuckPlayerSettingsNoParams : GlobalActivityStarter.ActivityParams diff --git a/duckplayer/duckplayer-impl/build.gradle b/duckplayer/duckplayer-impl/build.gradle index b7232b9ce2d2..e614341984f4 100644 --- a/duckplayer/duckplayer-impl/build.gradle +++ b/duckplayer/duckplayer-impl/build.gradle @@ -31,17 +31,26 @@ dependencies { anvil project(path: ':anvil-compiler') implementation project(path: ':anvil-annotations') implementation project(path: ':di') + implementation project(':common-ui') implementation project(':content-scope-scripts-api') implementation project(':app-build-config-api') implementation project(':js-messaging-api') + implementation project(':navigation-api') + implementation project(':settings-api') implementation project(':statistics') api AndroidX.dataStore.preferences ksp AndroidX.room.compiler + implementation AndroidX.appCompat implementation KotlinX.coroutines.android + implementation KotlinX.coroutines.core + implementation AndroidX.constraintLayout implementation AndroidX.core.ktx + implementation AndroidX.lifecycle.runtime.ktx + implementation AndroidX.lifecycle.viewModelKtx + implementation Google.android.material implementation Google.dagger implementation "com.squareup.logcat:logcat:_" diff --git a/duckplayer/duckplayer-impl/src/main/AndroidManifest.xml b/duckplayer/duckplayer-impl/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..819a5674cbe8 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStore.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStore.kt index a8d6b364f54c..42b6badab2de 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStore.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerDataStore.kt @@ -39,10 +39,14 @@ interface DuckPlayerDataStore { suspend fun getOverlayInteracted(): Boolean + fun observeOverlayInteracted(): Flow + suspend fun setOverlayInteracted(value: Boolean) suspend fun getPrivatePlayerMode(): String + fun observePrivatePlayerMode(): Flow + suspend fun setPrivatePlayerMode(value: String) } @@ -90,6 +94,10 @@ class SharedPreferencesDuckPlayerDataStore @Inject constructor( return overlayInteracted.first() } + override fun observeOverlayInteracted(): Flow { + return overlayInteracted + } + override suspend fun setOverlayInteracted(value: Boolean) { store.edit { prefs -> prefs[OVERLAY_INTERACTED] = value } } @@ -98,6 +106,10 @@ class SharedPreferencesDuckPlayerDataStore @Inject constructor( return privatePlayerMode.first() } + override fun observePrivatePlayerMode(): Flow { + return privatePlayerMode + } + override suspend fun setPrivatePlayerMode(value: String) { store.edit { prefs -> prefs[PRIVATE_PLAYER_MODE] = value } } diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt index 016d865af24a..d55e1ead99b6 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerFeatureRepository.kt @@ -26,6 +26,8 @@ import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch interface DuckPlayerFeatureRepository { @@ -35,6 +37,8 @@ interface DuckPlayerFeatureRepository { suspend fun getUserPreferences(): UserPreferences + fun observeUserPreferences(): Flow + fun setUserPreferences(userPreferences: UserPreferences) } @@ -74,10 +78,24 @@ class RealDuckPlayerFeatureRepository @Inject constructor( ) { appCoroutineScope.launch(dispatcherProvider.io()) { duckPlayerDataStore.setOverlayInteracted(userPreferences.overlayInteracted) - duckPlayerDataStore.setPrivatePlayerMode(userPreferences.privatePlayerMode.toString()) + duckPlayerDataStore.setPrivatePlayerMode(userPreferences.privatePlayerMode.value) } } + override fun observeUserPreferences(): Flow { + return duckPlayerDataStore.observePrivatePlayerMode() + .combine(duckPlayerDataStore.observeOverlayInteracted()) { privatePlayerMode, overlayInteracted -> + UserPreferences( + overlayInteracted = overlayInteracted, + privatePlayerMode = when (privatePlayerMode) { + Enabled.value -> Enabled + Disabled.value -> Disabled + else -> AlwaysAsk + }, + ) + } + } + override suspend fun getUserPreferences(): UserPreferences { return UserPreferences( overlayInteracted = duckPlayerDataStore.getOverlayInteracted(), diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettings.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettings.kt new file mode 100644 index 000000000000..645aad141a6b --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettings.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.content.Context +import android.view.View +import com.duckduckgo.anvil.annotations.PriorityKey +import com.duckduckgo.common.ui.view.listitem.OneLineListItem +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +@ContributesMultibinding(ActivityScope::class) +@PriorityKey(100) +class DuckPlayerSettingsTitle @Inject constructor() : DuckPlayerSettingsPlugin { + override fun getView(context: Context): View { + return OneLineListItem(context).apply { + setPrimaryText(context.getString(R.string.duck_player_setting_title)) + } + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsActivity.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsActivity.kt new file mode 100644 index 000000000000..166bf2d9eb88 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsActivity.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.duckduckgo.anvil.annotations.ContributeToActivityStarter +import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.common.ui.DuckDuckGoActivity +import com.duckduckgo.common.ui.view.dialog.RadioListAlertDialogBuilder +import com.duckduckgo.common.ui.viewbinding.viewBinding +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayerSettingsNoParams +import com.duckduckgo.duckplayer.api.PrivatePlayerMode +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled +import com.duckduckgo.duckplayer.impl.databinding.ActivityDuckPlayerSettingsBinding +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@InjectWith(ActivityScope::class) +@ContributeToActivityStarter(DuckPlayerSettingsNoParams::class) +class DuckPlayerSettingsActivity : DuckDuckGoActivity() { + + private val viewModel: DuckPlayerSettingsViewModel by bindViewModel() + private val binding: ActivityDuckPlayerSettingsBinding by viewBinding() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(binding.root) + setupToolbar(binding.includeToolbar.toolbar) + + configureUiEventHandlers() + observeViewModel() + } + + private fun configureUiEventHandlers() { + binding.duckPlayerModeSelector.setClickListener { + viewModel.duckPlayerModeSelectorClicked() + } + } + + private fun observeViewModel() { + viewModel.viewState + .flowWithLifecycle(lifecycle, Lifecycle.State.CREATED) + .onEach { viewState -> renderViewState(viewState) } + .launchIn(lifecycleScope) + + viewModel.commands + .flowWithLifecycle(lifecycle, Lifecycle.State.STARTED) + .onEach { processCommand(it) } + .launchIn(lifecycleScope) + } + + private fun processCommand(it: DuckPlayerSettingsViewModel.Command) { + when (it) { + is DuckPlayerSettingsViewModel.Command.OpenPlayerModeSelector -> { + launchPlayerModeSelector(it.current) + } + } + } + + private fun launchPlayerModeSelector(privatePlayerMode: PrivatePlayerMode) { + val options = + listOf( + Pair(Enabled, R.string.duck_player_mode_always), + Pair(Disabled, R.string.duck_player_mode_never), + Pair(AlwaysAsk, R.string.duck_player_mode_always_ask), + ) + RadioListAlertDialogBuilder(this) + .setTitle(getString(R.string.duck_player_mode_dialog_title)) + .setMessage(getString(R.string.duck_player_mode_dialog_description)) + .setOptions( + options.map { it.second }, + options.map { it.first }.indexOf(privatePlayerMode) + 1, + ) + .setPositiveButton(R.string.duck_player_save) + .setNegativeButton(R.string.duck_player_cancel) + .addEventListener( + object : RadioListAlertDialogBuilder.EventListener() { + override fun onPositiveButtonClicked(selectedItem: Int) { + val selectedPlayerMode = + when (selectedItem) { + 1 -> Enabled + 2 -> Disabled + else -> AlwaysAsk + } + viewModel.onPlayerModeSelected(selectedPlayerMode) + } + }, + ) + .show() + } + + private fun renderViewState(viewState: DuckPlayerSettingsViewModel.ViewState) { + binding.duckPlayerModeSelector.setSecondaryText( + when (viewState.privatePlayerMode) { + Enabled -> getString(R.string.duck_player_mode_always) + Disabled -> getString(R.string.duck_player_mode_never) + else -> getString(R.string.duck_player_mode_always_ask) + }, + ) + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsViewModel.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsViewModel.kt new file mode 100644 index 000000000000..9a11ca3c68ae --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/DuckPlayerSettingsViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.duckplayer.impl + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.duckplayer.api.DuckPlayer +import com.duckduckgo.duckplayer.api.PrivatePlayerMode +import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk +import com.duckduckgo.duckplayer.impl.DuckPlayerSettingsViewModel.Command.OpenPlayerModeSelector +import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking + +@ContributesViewModel(ActivityScope::class) +class DuckPlayerSettingsViewModel @Inject constructor( + private val duckPlayer: DuckPlayer, +) : ViewModel() { + + private val commandChannel = Channel(capacity = 1, onBufferOverflow = DROP_OLDEST) + val commands = commandChannel.receiveAsFlow() + + val viewState: StateFlow = duckPlayer.observeUserPreferences() + .map { ViewState(it.privatePlayerMode) } + .stateIn( + viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = runBlocking { ViewState(duckPlayer.getUserPreferences().privatePlayerMode) }, + ) + + sealed class Command { + data class OpenPlayerModeSelector(val current: PrivatePlayerMode) : Command() + } + + data class ViewState(val privatePlayerMode: PrivatePlayerMode = AlwaysAsk) + fun duckPlayerModeSelectorClicked() { + viewModelScope.launch { + commandChannel.send(OpenPlayerModeSelector(duckPlayer.getUserPreferences().privatePlayerMode)) + } + } + + fun onPlayerModeSelected(selectedPlayerMode: PrivatePlayerMode) { + viewModelScope.launch { + duckPlayer.setUserPreferences(overlayInteracted = false, privatePlayerMode = selectedPlayerMode.value) + } + } +} diff --git a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt index c3ebcb7b103b..c2d9621590d0 100644 --- a/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt +++ b/duckplayer/duckplayer-impl/src/main/java/com/duckduckgo/duckplayer/impl/RealDuckPlayer.kt @@ -28,15 +28,24 @@ import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled import com.squareup.anvil.annotations.ContributesBinding import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map private const val YOUTUBE_NO_COOKIE_HOST = "youtube-nocookie.com" +private const val YOUTUBE_HOST = "youtube.com" +private const val YOUTUBE_MOBILE_HOST = "m.youtube.com" @ContributesBinding(AppScope::class) class RealDuckPlayer @Inject constructor( private val duckPlayerFeatureRepository: DuckPlayerFeatureRepository, + private val duckPlayerFeature: DuckPlayerFeature, private val pixel: Pixel, ) : DuckPlayer { + override fun isDuckPlayerAvailable(): Boolean { + return duckPlayerFeature.self().isEnabled() + } + override fun setUserPreferences( overlayInteracted: Boolean, privatePlayerMode: String, @@ -55,6 +64,12 @@ class RealDuckPlayer @Inject constructor( } } + override fun observeUserPreferences(): Flow { + return duckPlayerFeatureRepository.observeUserPreferences().map { + UserPreferences(it.overlayInteracted, it.privatePlayerMode) + } + } + override fun sendDuckPlayerPixel( pixelName: String, pixelData: Map, @@ -96,7 +111,16 @@ class RealDuckPlayer @Inject constructor( return url.path?.takeIf { it.isNotBlank() }?.removePrefix("/")?.let { "duckplayer/$it" } } + override fun isYoutubeWatchUrl(uri: Uri): Boolean { + val host = uri.host?.removePrefix("www.") + return (host == YOUTUBE_HOST || host == YOUTUBE_MOBILE_HOST) && uri.pathSegments.firstOrNull() == "watch" + } + override fun createDuckPlayerUriFromYoutubeNoCookie(uri: Uri): String { return "${UrlScheme.duck}://player/${uri.getQueryParameter("videoID")}" } + + override fun createDuckPlayerUriFromYoutube(uri: Uri): String { + return "${UrlScheme.duck}://player/${uri.getQueryParameter("v")}" + } } diff --git a/duckplayer/duckplayer-impl/src/main/res/drawable/clean_tube_128.xml b/duckplayer/duckplayer-impl/src/main/res/drawable/clean_tube_128.xml new file mode 100644 index 000000000000..3749812ad7c9 --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/drawable/clean_tube_128.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/duckplayer/duckplayer-impl/src/main/res/layout/activity_duck_player_settings.xml b/duckplayer/duckplayer-impl/src/main/res/layout/activity_duck_player_settings.xml new file mode 100644 index 000000000000..b6f4ccae5b4f --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/layout/activity_duck_player_settings.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/duckplayer/duckplayer-impl/src/main/res/values/donottranslate.xml b/duckplayer/duckplayer-impl/src/main/res/values/donottranslate.xml new file mode 100644 index 000000000000..43d784cee51f --- /dev/null +++ b/duckplayer/duckplayer-impl/src/main/res/values/donottranslate.xml @@ -0,0 +1,28 @@ + + + + Save + Cancel + Duck Player + Duck Player + Duck Player provides a clean viewing experience without personalized ads and prevents viewing activity from influencing your YouTube recommendations. \nLearn More + Always + Never + Always Ask + Open Youtube videos in Duck Player? + Duck Player lets you watch YouTube without targeted ads in a theater-like experience in DuckDuckGo. + \ No newline at end of file diff --git a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/ProSettingsPlugin.kt b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/ProSettingsPlugin.kt index 485c124f5adc..bfb6db7462ba 100644 --- a/settings/settings-api/src/main/java/com/duckduckgo/settings/api/ProSettingsPlugin.kt +++ b/settings/settings-api/src/main/java/com/duckduckgo/settings/api/ProSettingsPlugin.kt @@ -34,3 +34,8 @@ interface SettingsPlugin { * This is the plugin for the subs settings */ interface ProSettingsPlugin : SettingsPlugin + +/** + * This is the plugin for Duck Player settings + */ +interface DuckPlayerSettingsPlugin : SettingsPlugin diff --git a/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/DuckPlayerSettingModule.kt b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/DuckPlayerSettingModule.kt new file mode 100644 index 000000000000..97266b059c35 --- /dev/null +++ b/settings/settings-impl/src/main/java/com/duckduckgo/settings/impl/DuckPlayerSettingModule.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2023 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.settings.impl + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.di.scopes.ActivityScope +import com.duckduckgo.settings.api.DuckPlayerSettingsPlugin + +@ContributesPluginPoint( + scope = ActivityScope::class, + boundType = DuckPlayerSettingsPlugin::class, +) +private interface DuckPlayerSettingsPluginTrigger