Skip to content

Commit

Permalink
Add Duck Player settings
Browse files Browse the repository at this point in the history
  • Loading branch information
CrisBarreiro committed Aug 7, 2024
1 parent 01cdb4e commit 1c70aab
Show file tree
Hide file tree
Showing 23 changed files with 686 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("."))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/com/duckduckgo/app/settings/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,6 +102,12 @@ class SettingsActivity : DuckDuckGoActivity() {
_proSettingsPlugin.getPlugins()
}

@Inject
lateinit var _duckPlayerSettingsPlugin: PluginPoint<DuckPlayerSettingsPlugin>
private val duckPlayerSettingsPlugin by lazy {
_duckPlayerSettingsPlugin.getPlugins()
}

private val viewsPrivacy
get() = binding.includeSettings.contentSettingsPrivacy

Expand Down Expand Up @@ -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) {
Expand All @@ -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() {
Expand Down Expand Up @@ -198,6 +215,7 @@ class SettingsActivity : DuckDuckGoActivity() {
updateSyncSetting(visible = it.showSyncSetting)
updateAutoconsent(it.isAutoconsentEnabled)
updatePrivacyPro(it.isPrivacyProEnabled)
updateDuckPlayer(it.isDuckPlayerEnabled)
}
}.launchIn(lifecycleScope)

Expand All @@ -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
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/duckduckgo/app/settings/SettingsViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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 {
Expand All @@ -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()
}

Expand Down Expand Up @@ -129,6 +133,7 @@ class SettingsViewModel @Inject constructor(
showSyncSetting = deviceSyncState.isFeatureEnabled(),
isAutoconsentEnabled = autoconsent.isSettingEnabled(),
isPrivacyProEnabled = subscriptions.isEligible(),
isDuckPlayerEnabled = duckPlayer.isDuckPlayerAvailable(),
),
)
}
Expand Down Expand Up @@ -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()) {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/res/layout/content_settings_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@
android:layout_height="wrap_content"
app:primaryText="@string/settingsAccessibility" />

<LinearLayout
android:id="@+id/settingsSectionDuckPlayer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical" >
</LinearLayout>

<com.duckduckgo.common.ui.view.listitem.OneLineListItem
android:id="@+id/aboutSetting"
android:layout_width="match_parent"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import com.duckduckgo.autoconsent.api.Autoconsent
import com.duckduckgo.autofill.api.AutofillCapabilityChecker
import com.duckduckgo.autofill.api.email.EmailManager
import com.duckduckgo.common.test.CoroutineTestRule
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
Expand Down Expand Up @@ -74,6 +75,9 @@ class SettingsViewModelTest {
@Mock
private lateinit var subscriptions: Subscriptions

@Mock
private lateinit var mockDuckPlayer: DuckPlayer

@get:Rule
val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()

Expand All @@ -98,6 +102,7 @@ class SettingsViewModelTest {
coroutineTestRule.testDispatcherProvider,
mockAutoconsent,
subscriptions,
mockDuckPlayer,
)

runTest {
Expand Down Expand Up @@ -483,4 +488,28 @@ class SettingsViewModelTest {
assertFalse(awaitItem().isAutoconsentEnabled)
}
}

@Test
fun whenDuckPlayerEnabledThroughRCSettingVisible() = runTest {
whenever(mockDuckPlayer.isDuckPlayerAvailable()).thenReturn(true)
testee.start()

testee.viewState().test {
val viewState = awaitItem()
assertTrue(viewState.isDuckPlayerEnabled)
cancelAndConsumeRemainingEvents()
}
}

@Test
fun whenDuckPlayerDisabledThroughRCSettingNotVisible() = runTest {
whenever(mockDuckPlayer.isDuckPlayerAvailable()).thenReturn(false)
testee.start()

testee.viewState().test {
val viewState = awaitItem()
assertFalse(viewState.isDuckPlayerEnabled)
cancelAndConsumeRemainingEvents()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.duckduckgo.duckplayer.impl

import app.cash.turbine.test
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.duckplayer.api.DuckPlayer
import com.duckduckgo.duckplayer.api.DuckPlayer.UserPreferences
import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk
import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled
import com.duckduckgo.duckplayer.impl.DuckPlayerSettingsViewModel.Command.OpenPlayerModeSelector
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever

@ExperimentalCoroutinesApi
class DuckPlayerSettingsViewModelTest {

@get:Rule
@Suppress("unused")
val coroutineRule = CoroutineTestRule()

private val duckPlayer: DuckPlayer = mock()
private lateinit var viewModel: DuckPlayerSettingsViewModel

@Before
fun setUp() {
runTest {
whenever(duckPlayer.getUserPreferences()).thenReturn(UserPreferences(overlayInteracted = true, privatePlayerMode = AlwaysAsk))
viewModel = DuckPlayerSettingsViewModel(duckPlayer)
}
}

@Test
fun whenDuckPlayerModeSelectorIsClichedThenEmitOpenPlayerModeSelector() = runTest {
viewModel.duckPlayerModeSelectorClicked()

viewModel.commands.test {
assertEquals(OpenPlayerModeSelector(AlwaysAsk), awaitItem())
}
}

@Test
fun whenPrivatePlayerModeIsSelectedThenUpdateUserPreferences() = runTest {
viewModel.onPlayerModeSelected(Disabled)

verify(duckPlayer).setUserPreferences(overlayInteracted = false, privatePlayerMode = Disabled.value)
}

@Test
fun whenViewModelIsCreatedAndPrivatePlayerModeIsDisabledThenEmitDisabled() = runTest {
whenever(duckPlayer.observeUserPreferences()).thenReturn(flowOf(UserPreferences(overlayInteracted = true, privatePlayerMode = Disabled)))
viewModel = DuckPlayerSettingsViewModel(duckPlayer)

viewModel.viewState.test {
assertEquals(Disabled, awaitItem().privatePlayerMode)
}
}
}
2 changes: 2 additions & 0 deletions duckplayer/duckplayer-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ kotlin {
dependencies {
implementation Kotlin.stdlib.jdk7
implementation AndroidX.core.ktx
implementation KotlinX.coroutines.core
coreLibraryDesugaring Android.tools.desugarJdkLibs
implementation project(path: ':common-utils')
implementation project(':navigation-api')
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,17 @@ import android.net.Uri
import com.duckduckgo.duckplayer.api.PrivatePlayerMode.AlwaysAsk
import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Disabled
import com.duckduckgo.duckplayer.api.PrivatePlayerMode.Enabled
import kotlinx.coroutines.flow.Flow

const val DUCK_PLAYER_ASSETS_PATH = "duckplayer/index.html"

/**
* DuckPlayer interface provides a set of methods for interacting with the DuckPlayer.
*/
interface DuckPlayer {

fun isDuckPlayerAvailable(): Boolean

/**
* Sends a pixel with the given name and data.
*
Expand All @@ -42,6 +46,13 @@ interface DuckPlayer {
*/
suspend fun getUserPreferences(): UserPreferences

/**
* Retrieves a flow of user preferences.
*
* @return The flow user preferences.
*/
fun observeUserPreferences(): Flow<UserPreferences>

/**
* Sets the user preferences.
*
Expand All @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
Loading

0 comments on commit 1c70aab

Please sign in to comment.