From 6c7a3cd5807a4ecbd4543ceb9bcd3f29c051f194 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Paczos?= Date: Fri, 17 Jan 2025 13:47:43 +0100 Subject: [PATCH] Additional "default browser" prompts: variant 2 --- .../app/browser/BrowserTabViewModelTest.kt | 29 + ...ultBrowserPromptsPrefsDataStoreImplTest.kt | 134 +++ .../duckduckgo/app/browser/BrowserActivity.kt | 81 +- .../app/browser/BrowserTabFragment.kt | 1 + .../app/browser/BrowserTabViewModel.kt | 14 +- .../app/browser/BrowserViewModel.kt | 66 ++ .../DefaultBrowserPromptsExperiment.kt | 49 + .../DefaultBrowserPromptsExperimentImpl.kt | 341 +++++++ ...DefaultBrowserPromptsExperimentVariants.kt | 79 ++ .../DefaultBrowserPromptsFeatureToggles.kt | 39 + .../DefaultBrowserPromptsDataStoreModule.kt | 43 + ...wser_prompts_experiment_class_diagram.puml | 218 +++++ ...pts_experiment_variant_2_flow_diagram.puml | 94 ++ .../store/DefaultBrowserPromptsDataStore.kt | 91 ++ .../ui/DefaultBrowserBottomSheetDialog.kt | 49 +- .../app/browser/omnibar/OmnibarLayout.kt | 2 + .../browser/omnibar/OmnibarLayoutViewModel.kt | 14 + .../res/drawable/ic_circle_7_accent_blue.xml | 7 + app/src/main/res/layout/view_new_omnibar.xml | 8 + .../res/layout/view_new_omnibar_bottom.xml | 8 + .../app/browser/BrowserViewModelTest.kt | 105 +++ ...DefaultBrowserPromptsExperimentImplTest.kt | 844 ++++++++++++++++++ .../omnibar/OmnibarLayoutViewModelTest.kt | 18 + 23 files changed, 2317 insertions(+), 17 deletions(-) create mode 100644 app/src/androidTest/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/store/DefaultBrowserPromptsPrefsDataStoreImplTest.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperiment.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentImpl.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentVariants.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsFeatureToggles.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/di/DefaultBrowserPromptsDataStoreModule.kt create mode 100644 app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/diagrams/default_browser_prompts_experiment_class_diagram.puml create mode 100644 app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/diagrams/default_browser_prompts_experiment_variant_2_flow_diagram.puml create mode 100644 app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/store/DefaultBrowserPromptsDataStore.kt create mode 100644 app/src/main/res/drawable/ic_circle_7_accent_blue.xml create mode 100644 app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentImplTest.kt diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index ec014a586b46..407a77e3f2d8 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -86,6 +86,7 @@ import com.duckduckgo.app.browser.commands.Command.ShowBackNavigationHistory import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.commands.NavigationCommand.Navigate import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_PAGE_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DuckPlayerJSHelper @@ -503,6 +504,9 @@ class BrowserTabViewModelTest { .create(ExtendedOnboardingFeatureToggles::class.java) private val extendedOnboardingPixelsPlugin = ExtendedOnboardingPixelsPlugin(extendedOnboardingFeatureToggles) + private val defaultBrowserPromptsExperimentShowOverflowMenuItemFlow = MutableStateFlow(false) + private val mockDefaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment = mock() + @Before fun before() = runTest { MockitoAnnotations.openMocks(this) @@ -607,6 +611,7 @@ class BrowserTabViewModelTest { whenever(mockAppBuildConfig.buildType).thenReturn("debug") whenever(mockDuckPlayer.observeUserPreferences()).thenReturn(flowOf(UserPreferences(false, AlwaysAsk))) whenever(mockHighlightsOnboardingExperimentManager.isHighlightsEnabled()).thenReturn(false) + whenever(mockDefaultBrowserPromptsExperiment.showOverflowMenuItem).thenReturn(defaultBrowserPromptsExperimentShowOverflowMenuItemFlow) testee = BrowserTabViewModel( statisticsUpdater = mockStatisticsUpdater, @@ -672,6 +677,7 @@ class BrowserTabViewModelTest { toggleReports = mockToggleReports, brokenSitePrompt = mockBrokenSitePrompt, tabStatsBucketing = mockTabStatsBucketing, + defaultBrowserPromptsExperiment = mockDefaultBrowserPromptsExperiment, ) testee.loadData("abc", null, false, false) @@ -5631,6 +5637,29 @@ class BrowserTabViewModelTest { verify(mockPixel).fire(AppPixelName.TAB_MANAGER_OPENED_FROM_SERP) } + @Test + fun whenInitialisedThenDefaultBrowserMenuButtonIsNotShown() { + assertFalse(browserViewState().showSelectDefaultBrowserMenuItem) + } + + @Test + fun whenDefaultBrowserMenuButtonVisibilityChangesThenShowIt() = runTest { + defaultBrowserPromptsExperimentShowOverflowMenuItemFlow.value = true + assertTrue(browserViewState().showSelectDefaultBrowserMenuItem) + } + + @Test + fun whenDefaultBrowserMenuButtonClickedThenNotifyExperiment() = runTest { + testee.onSetDefaultBrowserSelected() + verify(mockDefaultBrowserPromptsExperiment).onOverflowMenuItemClicked() + } + + @Test + fun whenPopupMenuLaunchedThenNotifyDefaultBrowserPromptsExperiment() = runTest { + testee.onPopupMenuLaunched() + verify(mockDefaultBrowserPromptsExperiment).onOverflowMenuOpened() + } + private fun givenTabManagerData() = runTest { val tabCount = "61-80" val active7d = "21+" diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/store/DefaultBrowserPromptsPrefsDataStoreImplTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/store/DefaultBrowserPromptsPrefsDataStoreImplTest.kt new file mode 100644 index 000000000000..a5bf5a003366 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/store/DefaultBrowserPromptsPrefsDataStoreImplTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 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.app.browser.defaultbrowsing.prompts.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage +import com.duckduckgo.common.test.CoroutineTestRule +import java.io.File +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +private const val DATA_STORE_NAME: String = "default_browser_prompts_test_data_store" + +@RunWith(AndroidJUnit4::class) +class DefaultBrowserPromptsPrefsDataStoreImplTest { + + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + + private lateinit var testDataStoreFile: File + private lateinit var testDataStore: DataStore + + @Before + fun setUp() { + testDataStoreFile = File.createTempFile(DATA_STORE_NAME, ".preferences_pb") + testDataStore = PreferenceDataStoreFactory.create( + scope = coroutinesTestRule.testScope, + produceFile = { testDataStoreFile }, + ) + } + + @After + fun tearDown() { + testDataStoreFile.delete() + } + + @Test + fun whenExperimentInitializedThenDefaultValueIsNotEnrolled() = runTest { + val testee = DefaultBrowserPromptsPrefsDataStoreImpl(testDataStore) + + assertEquals(testee.experimentStage.first(), ExperimentStage.NOT_ENROLLED) + } + + @Test + fun whenExperimentStageIsUpdatedThenValueIsPropagated() = runTest { + val testee = DefaultBrowserPromptsPrefsDataStoreImpl(testDataStore) + val expectedUpdates = listOf( + ExperimentStage.NOT_ENROLLED, + ExperimentStage.STAGE_1, + ) + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.experimentStage.toList(actualUpdates) + } + + testee.storeExperimentStage(ExperimentStage.STAGE_1) + + assertEquals(expectedUpdates, actualUpdates) + } + + @Test + fun whenExperimentInitializedThenShowOverflowMenuItemIsDisabled() = runTest { + val testee = DefaultBrowserPromptsPrefsDataStoreImpl(testDataStore) + + assertFalse(testee.showOverflowMenuItem.first()) + } + + @Test + fun whenShowOverflowMenuItemIsUpdatedThenValueIsPropagated() = runTest { + val testee = DefaultBrowserPromptsPrefsDataStoreImpl(testDataStore) + val expectedUpdates = listOf( + false, // initial value + true, + ) + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.showOverflowMenuItem.toList(actualUpdates) + } + + testee.storeShowOverflowMenuItemState(show = true) + + assertEquals(expectedUpdates, actualUpdates) + } + + @Test + fun whenExperimentInitializedThenHighlightOverflowMenuIconIsDisabled() = runTest { + val testee = DefaultBrowserPromptsPrefsDataStoreImpl(testDataStore) + + assertFalse(testee.highlightOverflowMenuIcon.first()) + } + + @Test + fun whenHighlightOverflowMenuIconIsUpdatedThenValueIsPropagated() = runTest { + val testee = DefaultBrowserPromptsPrefsDataStoreImpl(testDataStore) + val expectedUpdates = listOf( + false, // initial value + true, + ) + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.highlightOverflowMenuIcon.toList(actualUpdates) + } + + testee.storeHighlightOverflowMenuIconState(highlight = true) + + assertEquals(expectedUpdates, actualUpdates) + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 73aa5fbb9989..45b604dacfaf 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -38,8 +38,12 @@ import androidx.webkit.WebViewFeature import com.duckduckgo.anvil.annotations.InjectWith import com.duckduckgo.app.browser.BrowserViewModel.Command import com.duckduckgo.app.browser.BrowserViewModel.Command.Query +import com.duckduckgo.app.browser.BrowserViewModel.Command.ShowSystemDefaultAppsActivity +import com.duckduckgo.app.browser.BrowserViewModel.Command.ShowSystemDefaultBrowserDialog import com.duckduckgo.app.browser.databinding.ActivityBrowserBinding import com.duckduckgo.app.browser.databinding.IncludeOmnibarToolbarMockupBinding +import com.duckduckgo.app.browser.defaultbrowsing.prompts.ui.DefaultBrowserBottomSheetDialog +import com.duckduckgo.app.browser.defaultbrowsing.prompts.ui.DefaultBrowserBottomSheetDialog.EventListener import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.BOTTOM import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition.TOP import com.duckduckgo.app.browser.shortcut.ShortcutBuilder @@ -49,9 +53,11 @@ import com.duckduckgo.app.feedback.ui.common.FeedbackActivity import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.fire.DataClearerForegroundAppRestartPixel import com.duckduckgo.app.firebutton.FireButtonStore -import com.duckduckgo.app.global.* +import com.duckduckgo.app.global.ApplicationClearDataState import com.duckduckgo.app.global.events.db.UserEventsStore +import com.duckduckgo.app.global.intentText import com.duckduckgo.app.global.rating.PromptCount +import com.duckduckgo.app.global.sanitize import com.duckduckgo.app.global.view.ClearDataAction import com.duckduckgo.app.global.view.FireDialog import com.duckduckgo.app.global.view.renderIfChanged @@ -161,6 +167,22 @@ open class BrowserActivity : DuckDuckGoActivity() { } } + private val startDefaultBrowserSystemDialogForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> + if (result.resultCode == RESULT_OK) { + viewModel.onSystemDefaultBrowserDialogSuccess() + } else { + viewModel.onSystemDefaultBrowserDialogCanceled() + } + } + + private val startDefaultAppsSystemActivityForResult = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + viewModel.onSystemDefaultAppsActivityClosed() + } + + private var setAsDefaultBrowserDialog: DefaultBrowserBottomSheetDialog? = null + @SuppressLint("MissingSuperCall") override fun onCreate(savedInstanceState: Bundle?) { super.daggerInject() @@ -178,6 +200,7 @@ open class BrowserActivity : DuckDuckGoActivity() { binding.bottomMockupToolbar.appBarLayoutMockup.gone() binding.topMockupToolbar } + BOTTOM -> { binding.topMockupToolbar.appBarLayoutMockup.gone() binding.bottomMockupToolbar @@ -299,7 +322,10 @@ open class BrowserActivity : DuckDuckGoActivity() { transaction.commit() } - override fun onKeyLongPress(keyCode: Int, event: KeyEvent?): Boolean { + override fun onKeyLongPress( + keyCode: Int, + event: KeyEvent?, + ): Boolean { return if (keyCode == KeyEvent.KEYCODE_BACK) { currentTab?.onLongPressBackButton() true @@ -455,6 +481,10 @@ open class BrowserActivity : DuckDuckGoActivity() { is Command.ShowAppFeedbackPrompt -> showGiveFeedbackDialog(command.promptCount) is Command.LaunchFeedbackView -> startActivity(FeedbackActivity.intent(this)) is Command.OpenSavedSite -> currentTab?.submitQuery(command.url) + is Command.ShowSetAsDefaultBrowserDialog -> showSetAsDefaultBrowserDialog() + is Command.HideSetAsDefaultBrowserDialog -> hideSetAsDefaultBrowserDialog() + is ShowSystemDefaultAppsActivity -> showSystemDefaultAppsActivity(command.intent) + is ShowSystemDefaultBrowserDialog -> showSystemDefaultBrowserDialog(command.intent) } } @@ -713,6 +743,7 @@ open class BrowserActivity : DuckDuckGoActivity() { override fun onDialogShown() { viewModel.onAppRatingDialogShown(promptCount) } + override fun onDialogCancelled() { viewModel.onUserCancelledRateAppDialog(promptCount) } @@ -764,6 +795,52 @@ open class BrowserActivity : DuckDuckGoActivity() { val originalInstanceState: Bundle?, val newInstanceState: Bundle?, ) + + private fun showSetAsDefaultBrowserDialog() { + val dialog = DefaultBrowserBottomSheetDialog(context = this) + dialog.eventListener = object : EventListener { + override fun onShown() { + viewModel.onSetDefaultBrowserDialogShown() + } + + override fun onDismissed() { + viewModel.onSetDefaultBrowserDismissed() + } + + override fun onSetBrowserButtonClicked() { + viewModel.onSetDefaultBrowserConfirmationButtonClicked() + } + + override fun onNotNowButtonClicked() { + viewModel.onSetDefaultBrowserNotNowButtonClicked() + } + } + dialog.show() + setAsDefaultBrowserDialog = dialog + } + + private fun hideSetAsDefaultBrowserDialog() { + setAsDefaultBrowserDialog?.dismiss() + setAsDefaultBrowserDialog = null + } + + private fun showSystemDefaultAppsActivity(intent: Intent) { + try { + startDefaultAppsSystemActivityForResult.launch(intent) + viewModel.onSystemDefaultAppsActivityOpened() + } catch (ex: Exception) { + Timber.e(ex) + } + } + + private fun showSystemDefaultBrowserDialog(intent: Intent) { + try { + startDefaultBrowserSystemDialogForResult.launch(intent) + viewModel.onSystemDefaultBrowserDialogShown() + } catch (ex: Exception) { + Timber.e(ex) + } + } } // Temporary class to keep track of latest visited tabs, keeping unique ids. diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index f74ef8f38970..1b56745e73dc 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -1046,6 +1046,7 @@ class BrowserTabFragment : binding.rootView.postDelayed(POPUP_MENU_DELAY) { if (isAdded) { popupMenu.show(binding.rootView, omnibar.toolbar) + viewModel.onPopupMenuLaunched() if (isActiveCustomTab()) { pixel.fire(CustomTabPixelNames.CUSTOM_TABS_MENU_OPENED) } else { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 81d365dae0a9..dab59ca8c3ae 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -158,6 +158,7 @@ import com.duckduckgo.app.browser.commands.Command.WebShareRequest import com.duckduckgo.app.browser.commands.Command.WebViewError import com.duckduckgo.app.browser.commands.NavigationCommand import com.duckduckgo.app.browser.customtabs.CustomTabPixelNames +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DUCK_PLAYER_PAGE_FEATURE_NAME import com.duckduckgo.app.browser.duckplayer.DuckPlayerJSHelper @@ -434,6 +435,7 @@ class BrowserTabViewModel @Inject constructor( private val toggleReports: ToggleReports, private val brokenSitePrompt: BrokenSitePrompt, private val tabStatsBucketing: TabStatsBucketing, + private val defaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment, ) : WebViewClientListener, EditSavedSiteListener, DeleteBookmarkListener, @@ -669,6 +671,12 @@ class BrowserTabViewModel @Inject constructor( } .flowOn(dispatchers.main()) .launchIn(viewModelScope) + + defaultBrowserPromptsExperiment.showOverflowMenuItem + .onEach { + browserViewState.value = currentBrowserViewState().copy(showSelectDefaultBrowserMenuItem = it) + } + .launchIn(viewModelScope) } fun loadData( @@ -2428,7 +2436,7 @@ class BrowserTabViewModel @Inject constructor( } fun onSetDefaultBrowserSelected() { - // no-op, to be implemented + defaultBrowserPromptsExperiment.onOverflowMenuItemClicked() } fun onShareSelected() { @@ -2540,6 +2548,10 @@ class BrowserTabViewModel @Inject constructor( } } + fun onPopupMenuLaunched() { + defaultBrowserPromptsExperiment.onOverflowMenuOpened() + } + fun userRequestedOpeningNewTab( longPress: Boolean = false, ) { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index b21bb639abd3..0b3f3adef479 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -16,6 +16,7 @@ package com.duckduckgo.app.browser +import android.content.Intent import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer @@ -23,7 +24,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesRemoteFeature import com.duckduckgo.anvil.annotations.ContributesViewModel +import com.duckduckgo.app.browser.BrowserViewModel.Command.HideSetAsDefaultBrowserDialog import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment.Command.OpenMessageDialog +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment.Command.OpenSystemDefaultAppsActivity +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment.Command.OpenSystemDefaultBrowserDialog import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.fire.DataClearer import com.duckduckgo.app.generalsettings.showonapplaunch.ShowOnAppLaunchFeature @@ -74,6 +80,7 @@ class BrowserViewModel @Inject constructor( private val skipUrlConversionOnNewTabFeature: SkipUrlConversionOnNewTabFeature, private val showOnAppLaunchFeature: ShowOnAppLaunchFeature, private val showOnAppLaunchOptionHandler: ShowOnAppLaunchOptionHandler, + private val defaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment, ) : ViewModel(), CoroutineScope { @@ -92,6 +99,10 @@ class BrowserViewModel @Inject constructor( data class ShowAppRatingPrompt(val promptCount: PromptCount) : Command() data class ShowAppFeedbackPrompt(val promptCount: PromptCount) : Command() data class OpenSavedSite(val url: String) : Command() + data object ShowSetAsDefaultBrowserDialog : Command() + data object HideSetAsDefaultBrowserDialog : Command() + data class ShowSystemDefaultBrowserDialog(val intent: Intent) : Command() + data class ShowSystemDefaultAppsActivity(val intent: Intent) : Command() } var viewState: MutableLiveData = MutableLiveData().also { @@ -139,6 +150,23 @@ class BrowserViewModel @Inject constructor( init { appEnjoymentPromptEmitter.promptType.observeForever(appEnjoymentObserver) + viewModelScope.launch { + defaultBrowserPromptsExperiment.commands.collect { + when (it) { + OpenMessageDialog -> { + command.value = Command.ShowSetAsDefaultBrowserDialog + } + + is OpenSystemDefaultAppsActivity -> { + command.value = Command.ShowSystemDefaultAppsActivity(it.intent) + } + + is OpenSystemDefaultBrowserDialog -> { + command.value = Command.ShowSystemDefaultBrowserDialog(it.intent) + } + } + } + } } suspend fun onNewTabRequested(sourceTabId: String? = null): String { @@ -309,6 +337,44 @@ class BrowserViewModel @Inject constructor( tabRepository.updateTabLastAccess(tabId) } } + + fun onSetDefaultBrowserDialogShown() { + defaultBrowserPromptsExperiment.onMessageDialogShown() + } + + fun onSetDefaultBrowserDismissed() { + defaultBrowserPromptsExperiment.onMessageDialogDismissed() + } + + fun onSetDefaultBrowserConfirmationButtonClicked() { + command.value = HideSetAsDefaultBrowserDialog + defaultBrowserPromptsExperiment.onMessageDialogConfirmationButtonClicked() + } + + fun onSetDefaultBrowserNotNowButtonClicked() { + command.value = HideSetAsDefaultBrowserDialog + defaultBrowserPromptsExperiment.onMessageDialogNotNowButtonClicked() + } + + fun onSystemDefaultBrowserDialogShown() { + defaultBrowserPromptsExperiment.onSystemDefaultBrowserDialogShown() + } + + fun onSystemDefaultBrowserDialogSuccess() { + defaultBrowserPromptsExperiment.onSystemDefaultBrowserDialogSuccess() + } + + fun onSystemDefaultBrowserDialogCanceled() { + defaultBrowserPromptsExperiment.onSystemDefaultBrowserDialogCanceled() + } + + fun onSystemDefaultAppsActivityOpened() { + defaultBrowserPromptsExperiment.onSystemDefaultAppsActivityOpened() + } + + fun onSystemDefaultAppsActivityClosed() { + defaultBrowserPromptsExperiment.onSystemDefaultAppsActivityClosed() + } } /** diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperiment.kt b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperiment.kt new file mode 100644 index 000000000000..69dd81f54adf --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperiment.kt @@ -0,0 +1,49 @@ +/* + * 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.app.browser.defaultbrowsing.prompts + +import android.content.Intent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface DefaultBrowserPromptsExperiment { + + val highlightOverflowMenu: StateFlow + val showOverflowMenuItem: StateFlow + val commands: Flow + + fun onOverflowMenuOpened() + fun onOverflowMenuItemClicked() + + fun onMessageDialogShown() + fun onMessageDialogDismissed() + fun onMessageDialogConfirmationButtonClicked() + fun onMessageDialogNotNowButtonClicked() + + fun onSystemDefaultBrowserDialogShown() + fun onSystemDefaultBrowserDialogSuccess() + fun onSystemDefaultBrowserDialogCanceled() + + fun onSystemDefaultAppsActivityOpened() + fun onSystemDefaultAppsActivityClosed() + + sealed class Command { + data object OpenMessageDialog : Command() + data class OpenSystemDefaultBrowserDialog(val intent: Intent) : Command() + data class OpenSystemDefaultAppsActivity(val intent: Intent) : Command() + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentImpl.kt b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentImpl.kt new file mode 100644 index 000000000000..28154b5c2ca7 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentImpl.kt @@ -0,0 +1,341 @@ +/* + * Copyright (c) 2025 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.app.browser.defaultbrowsing.prompts + +import android.content.Context +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector +import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserSystemSettings +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment.Command +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment.Command.OpenMessageDialog +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsFeatureToggles.AdditionalPromptsCohortName +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.CONVERTED +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.ENROLLED +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.NOT_ENROLLED +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STAGE_1 +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STAGE_2 +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STOPPED +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.global.DefaultRoleBrowserDialog +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.app.usage.app.AppDaysUsedRepository +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import com.squareup.moshi.Moshi +import dagger.SingleInstanceIn +import java.time.ZonedDateTime +import java.util.Date +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import timber.log.Timber + +/** + * Introduced by [this Asana task](https://app.asana.com/0/1208671518894266/1207295380941379/f). + * + * For more information refer to the diagrams in [com.duckduckgo.app.browser.defaultbrowsing.prompts.diagrams]. + */ +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +@ContributesMultibinding( + AppScope::class, + boundType = PrivacyConfigCallbackPlugin::class, +) +@ContributesBinding( + scope = AppScope::class, + boundType = DefaultBrowserPromptsExperiment::class, +) +@SingleInstanceIn(scope = AppScope::class) +class DefaultBrowserPromptsExperimentImpl @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + private val dispatchers: DispatcherProvider, + private val applicationContext: Context, + private val defaultBrowserPromptsFeatureToggles: DefaultBrowserPromptsFeatureToggles, + private val defaultBrowserDetector: DefaultBrowserDetector, + private val defaultRoleBrowserDialog: DefaultRoleBrowserDialog, + private val appDaysUsedRepository: AppDaysUsedRepository, + private val defaultBrowserPromptsDataStore: DefaultBrowserPromptsDataStore, + private val experimentStageEvaluatorPluginPoint: PluginPoint, + moshi: Moshi, +) : DefaultBrowserPromptsExperiment, MainProcessLifecycleObserver, PrivacyConfigCallbackPlugin { + + private val _commands = Channel(capacity = Channel.CONFLATED) + override val commands: Flow = _commands.receiveAsFlow() + + override val showOverflowMenuItem: StateFlow = defaultBrowserPromptsDataStore.showOverflowMenuItem.stateIn( + scope = appCoroutineScope, + started = SharingStarted.Lazily, + initialValue = false, + ) + + override val highlightOverflowMenu: StateFlow = defaultBrowserPromptsDataStore.highlightOverflowMenuIcon.stateIn( + scope = appCoroutineScope, + started = SharingStarted.Lazily, + initialValue = false, + ) + + @VisibleForTesting + data class FeatureSettings( + val activeDaysUntilStage1: Int, + val activeDaysUntilStage2: Int, + val activeDaysUntilStop: Int, + ) + + private val featureSettingsJsonAdapter = moshi.adapter(FeatureSettings::class.java) + + /** + * Caches deserialized [Toggle.getSettings] for [DefaultBrowserPromptsFeatureToggles.defaultBrowserAdditionalPrompts202501]. + * + * Since we're re-evaluating the experiment on every process' resume, + * this allows us to avoid constantly deserializing the same value. + * + * The value is recomputed on first launch, and on each subsequent privacy config change via [onPrivacyConfigDownloaded]. + */ + private var featureSettings: FeatureSettings? = null + + /** + * Provides a workaround for cases where the default system browser selection dialog is not available. + * + * If this [Deferred] is active when [onSystemDefaultBrowserDialogCanceled] is called, + * then it means that we should fallback to opening the default apps activity instead. + * + * If this [Deferred] is complete or cancelled, it means that we should ignore the dialog cancellation as it was likely intentional by the user. + * + * More context in [this Asana task](https://app.asana.com/0/0/1208996977455495/f). + */ + private var browserSelectionWindowFallbackDeferred: Deferred? = null + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + appCoroutineScope.launch { + evaluate() + } + } + + override fun onPrivacyConfigDownloaded() { + appCoroutineScope.launch { + featureSettings = defaultBrowserPromptsFeatureToggles.parseFeatureSettings() + evaluate() + } + } + + private suspend fun evaluate() { + val isEnrolled = defaultBrowserPromptsFeatureToggles.defaultBrowserAdditionalPrompts202501().getCohort() != null + val isDefaultBrowser = defaultBrowserDetector.isDefaultBrowser() + val isEligible = isEnrolled || !isDefaultBrowser + if (!isEligible) { + return + } + + val hasConvertedBefore = defaultBrowserPromptsDataStore.experimentStage.first() == CONVERTED + val isStopped = defaultBrowserPromptsDataStore.experimentStage.first() == STOPPED + if (hasConvertedBefore || isStopped) { + return + } + + val activeCohortName = defaultBrowserPromptsFeatureToggles.getOrAssignCohort() + val currentExperimentStage = defaultBrowserPromptsDataStore.experimentStage.first() + if (activeCohortName == null && currentExperimentStage == NOT_ENROLLED) { + // The user wasn't enrolled before and wasn't enrolled now either. + return + } + + val newExperimentStage = if (activeCohortName == null) { + // If experiment was underway but we lost the cohort name, it means that the experiment was remotely disabled. + STOPPED + } else if (isDefaultBrowser) { + CONVERTED + } else { + /** + * The [appDaysUsedRepository] expects a [Date] but the experiment framework stores the enrollment date as [ZonedDateTime], + * so we're doing a conversion here. + */ + val enrollmentDateGMT = defaultBrowserPromptsFeatureToggles.getEnrollmentDate() ?: run { + Timber.e("Missing enrollment date even though cohort is assigned.") + return + } + + val configSettings = featureSettings ?: run { + // If feature settings weren't cached before, deserialize and cache them now. + val parsedSettings = defaultBrowserPromptsFeatureToggles.parseFeatureSettings() + featureSettings = parsedSettings + parsedSettings + } ?: run { + Timber.e("Failed to obtain feature settings.") + return + } + + when (currentExperimentStage) { + NOT_ENROLLED -> ENROLLED + + ENROLLED -> { + if (appDaysUsedRepository.getNumberOfDaysAppUsedSinceDate(enrollmentDateGMT) >= configSettings.activeDaysUntilStage1) { + STAGE_1 + } else { + null + } + } + + STAGE_1 -> { + if (appDaysUsedRepository.getNumberOfDaysAppUsedSinceDate(enrollmentDateGMT) >= configSettings.activeDaysUntilStage2) { + STAGE_2 + } else { + null + } + } + + STAGE_2 -> { + if (appDaysUsedRepository.getNumberOfDaysAppUsedSinceDate(enrollmentDateGMT) >= configSettings.activeDaysUntilStop) { + STOPPED + } else { + null + } + } + + STOPPED, CONVERTED -> null + } + } + + if (newExperimentStage != null) { + defaultBrowserPromptsDataStore.storeExperimentStage(newExperimentStage) + + val action = experimentStageEvaluatorPluginPoint.getPlugins().first { it.targetCohort == activeCohortName }.evaluate(newExperimentStage) + if (action.showMessageDialog) { + _commands.send(OpenMessageDialog) + } + defaultBrowserPromptsDataStore.storeShowOverflowMenuItemState(action.showOverflowMenuItem) + defaultBrowserPromptsDataStore.storeHighlightOverflowMenuIconState(action.highlightOverflowMenu) + } + } + + override fun onOverflowMenuOpened() { + appCoroutineScope.launch { + defaultBrowserPromptsDataStore.storeHighlightOverflowMenuIconState(highlight = false) + } + } + + override fun onOverflowMenuItemClicked() { + appCoroutineScope.launch { + launchBestSelectionWindow() + } + } + + override fun onMessageDialogShown() { + } + + override fun onMessageDialogDismissed() { + } + + override fun onMessageDialogConfirmationButtonClicked() { + appCoroutineScope.launch { + launchBestSelectionWindow() + } + } + + override fun onMessageDialogNotNowButtonClicked() { + } + + override fun onSystemDefaultBrowserDialogShown() { + browserSelectionWindowFallbackDeferred = appCoroutineScope.async { + delay(FALLBACK_TO_DEFAULT_APPS_SCREEN_THRESHOLD_MILLIS) + } + } + + override fun onSystemDefaultBrowserDialogSuccess() { + } + + override fun onSystemDefaultBrowserDialogCanceled() { + appCoroutineScope.launch { + if (browserSelectionWindowFallbackDeferred?.isActive == true) { + browserSelectionWindowFallbackDeferred?.cancel() + launchSystemDefaultAppsActivity() + } + } + } + + override fun onSystemDefaultAppsActivityOpened() { + } + + override fun onSystemDefaultAppsActivityClosed() { + } + + private suspend fun launchBestSelectionWindow() { + val command = defaultRoleBrowserDialog.createIntent(applicationContext)?.let { + Command.OpenSystemDefaultBrowserDialog(intent = it) + } + if (command != null) { + _commands.send(command) + } else { + launchSystemDefaultAppsActivity() + } + } + + private suspend fun launchSystemDefaultAppsActivity() { + val command = Command.OpenSystemDefaultAppsActivity(DefaultBrowserSystemSettings.intent()) + _commands.send(command) + } + + private suspend fun DefaultBrowserPromptsFeatureToggles.parseFeatureSettings(): FeatureSettings? = withContext(dispatchers.io()) { + defaultBrowserAdditionalPrompts202501().getSettings()?.let { settings -> + try { + featureSettingsJsonAdapter.fromJson(settings) + } catch (e: Exception) { + Timber.e(e) + null + } + } + } + + private fun DefaultBrowserPromptsFeatureToggles.getEnrollmentDate(): Date? = + defaultBrowserAdditionalPrompts202501().getCohort()?.enrollmentDateET?.let { enrollmentZonedDateET -> + val instant = ZonedDateTime.parse(enrollmentZonedDateET).toInstant() + return Date.from(instant) + } + + private fun DefaultBrowserPromptsFeatureToggles.getOrAssignCohort(): AdditionalPromptsCohortName? { + for (cohort in AdditionalPromptsCohortName.entries) { + if (defaultBrowserAdditionalPrompts202501().isEnabled(cohort)) { + return cohort + } + } + return null + } + + companion object { + const val FALLBACK_TO_DEFAULT_APPS_SCREEN_THRESHOLD_MILLIS = 500L + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentVariants.kt b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentVariants.kt new file mode 100644 index 000000000000..f364bd78f91f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentVariants.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 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.app.browser.defaultbrowsing.prompts + +import com.duckduckgo.anvil.annotations.ContributesPluginPoint +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsFeatureToggles.AdditionalPromptsCohortName +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.CONVERTED +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.ENROLLED +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.NOT_ENROLLED +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STAGE_1 +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STAGE_2 +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STOPPED +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesMultibinding +import javax.inject.Inject + +data class DefaultBrowserPromptsExperimentStageAction( + val showMessageDialog: Boolean, + val showOverflowMenuItem: Boolean, + val highlightOverflowMenu: Boolean, +) { + companion object { + val disableAll = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ) + } +} + +@ContributesPluginPoint(scope = AppScope::class) +interface DefaultBrowserPromptsExperimentStageEvaluator { + val targetCohort: AdditionalPromptsCohortName + suspend fun evaluate(newStage: ExperimentStage): DefaultBrowserPromptsExperimentStageAction + + @ContributesMultibinding(scope = AppScope::class) + class Variant2 @Inject constructor() : DefaultBrowserPromptsExperimentStageEvaluator { + + override val targetCohort = AdditionalPromptsCohortName.VARIANT_2 + + override suspend fun evaluate(newStage: ExperimentStage): DefaultBrowserPromptsExperimentStageAction = + when (newStage) { + NOT_ENROLLED -> DefaultBrowserPromptsExperimentStageAction.disableAll + + ENROLLED -> DefaultBrowserPromptsExperimentStageAction.disableAll + + STAGE_1 -> DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = true, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ) + + STAGE_2 -> DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = true, + highlightOverflowMenu = true, + ) + + STOPPED -> DefaultBrowserPromptsExperimentStageAction.disableAll + + CONVERTED -> DefaultBrowserPromptsExperimentStageAction.disableAll + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsFeatureToggles.kt b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsFeatureToggles.kt new file mode 100644 index 000000000000..a02f0b3d6dd0 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsFeatureToggles.kt @@ -0,0 +1,39 @@ +/* + * 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.app.browser.defaultbrowsing.prompts + +import com.duckduckgo.anvil.annotations.ContributesRemoteFeature +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State.CohortName + +@ContributesRemoteFeature( + scope = AppScope::class, + featureName = "defaultBrowserPrompts", +) +interface DefaultBrowserPromptsFeatureToggles { + + @Toggle.DefaultValue(false) + fun self(): Toggle + + @Toggle.DefaultValue(false) + fun defaultBrowserAdditionalPrompts202501(): Toggle + + enum class AdditionalPromptsCohortName(override val cohortName: String) : CohortName { + VARIANT_2("variant_2"), + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/di/DefaultBrowserPromptsDataStoreModule.kt b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/di/DefaultBrowserPromptsDataStoreModule.kt new file mode 100644 index 000000000000..7f2679a5fd88 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/di/DefaultBrowserPromptsDataStoreModule.kt @@ -0,0 +1,43 @@ +/* + * 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.app.browser.defaultbrowsing.prompts.di + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.preferencesDataStore +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import javax.inject.Qualifier + +@ContributesTo(AppScope::class) +@Module +object DefaultBrowserPromptsDataStoreModule { + + private val Context.defaultBrowserPromptsDataStore: DataStore by preferencesDataStore( + name = "default_browser_prompts", + ) + + @Provides + @DefaultBrowserPrompts + fun defaultBrowserPromptsDataStore(context: Context): DataStore = context.defaultBrowserPromptsDataStore +} + +@Qualifier +annotation class DefaultBrowserPrompts diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/diagrams/default_browser_prompts_experiment_class_diagram.puml b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/diagrams/default_browser_prompts_experiment_class_diagram.puml new file mode 100644 index 000000000000..9fb8bb7b96b5 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/diagrams/default_browser_prompts_experiment_class_diagram.puml @@ -0,0 +1,218 @@ +@startuml +allowmixing + +component MainProcessLifecycleObserver +note top of MainProcessLifecycleObserver + The implementation class is the process observer so that it can check + whether DDG is the default browser app whenever the process resumes. + This allows us to check the state on app launch, + but also when app is already running and user changed the default + either via the system dialog or manually in system settings. +end note + +abstract class DefaultBrowserPromptsExperimentCommand as "(sealed class) DefaultBrowserPromptsExperiment.Command" + +class OpenMessageDialog as "(data object) OpenMessageDialog" + +class OpenSystemDefaultBrowserDialog as "(data class) OpenSystemDefaultBrowserDialog" { + + intent: Intent +} + +class OpenSystemDefaultAppsActivity as "(data class) OpenSystemDefaultAppsActivity" { + + intent: Intent +} + +DefaultBrowserPromptsExperimentCommand <|-- OpenMessageDialog : extends +DefaultBrowserPromptsExperimentCommand <|-- OpenSystemDefaultBrowserDialog : extends +DefaultBrowserPromptsExperimentCommand <|-- OpenSystemDefaultAppsActivity : extends + +interface DefaultBrowserPromptsExperiment { + + val highlightOverflowMenu: StateFlow + + val showOverflowMenuItem: StateFlow + + val commands: Flow + + + fun onOverflowMenuOpened() + + fun onOverflowMenuItemClicked() + + + fun onMessageDialogShown() + + fun onMessageDialogDismissed() + + fun onMessageDialogConfirmationButtonClicked() + + fun onMessageDialogNotNowButtonClicked() + + + fun onSystemDefaultBrowserDialogShown() + + fun onSystemDefaultBrowserDialogSuccess() + + fun onSystemDefaultBrowserDialogCanceled() + + + fun onSystemDefaultAppsActivityOpened() + + fun onSystemDefaultAppsActivityClosed() +} + +DefaultBrowserPromptsExperiment::commands -> DefaultBrowserPromptsExperimentCommand : uses + +note left of DefaultBrowserPromptsExperiment::commands + Backed by a //Conflated Channel// so that the experiment + can prepare an intent to be launched without being coupled + with Activity/Fragment's lifecycle. + + The Activity/Fragment will pick up and execute the intent whenever its ready to do so. +end note + +enum AdditionalPromptsCohortName { + + CONTROL("control") + + VARIANT_1("variant_1") + + VARIANT_2("variant_2") + + VARIANT_3("variant_3") +} + +interface DefaultBrowserPromptsFeatureToggles { + + additionalPrompts(): Toggle +} + +DefaultBrowserPromptsFeatureToggles -> AdditionalPromptsCohortName : uses + +enum ExperimentStage { + + NOT_ENROLLED + + ENROLLED + + STAGE_1 + + STAGE_2 + + STOPPED + + CONVERTED +} + +interface DefaultBrowserPromptsDataStore { + + val experimentStage: Flow + + val showOverflowMenuItem: Flow + + val highlightOverflowMenuIcon: Flow + + suspend fun storeExperimentStage(stage: ExperimentStage) + + suspend fun storeShowOverflowMenuItemState(show: Boolean) + + suspend fun storeHighlightOverflowMenuIconState(highlight: Boolean) +} + +note right of ExperimentStage::CONVERTED + Notes if the user has already set the DDG browser as default while enrolled. + Prevents counting users that keep changing the default browser multiple times. + If a user converts, the experiment permanently stops for them. +end note + +DefaultBrowserPromptsDataStore::experimentStage -> ExperimentStage : returns + +class DefaultBrowserPromptsDataStoreImpl { + - val dataStore: DataStore +} + +DefaultBrowserPromptsDataStore <|-- DefaultBrowserPromptsDataStoreImpl : implements + +class DefaultBrowserPromptsExperimentStageAction as "(data class) DefaultBrowserPromptsExperimentStageAction" { + + val showMessageDialog: Boolean + + val showOverflowMenuItem: Boolean + + val highlightOverflowMenu: Boolean +} + +interface DefaultBrowserPromptsExperimentStageEvaluator { + + val targetCohort: AdditionalPromptsCohortName + + suspend fun evaluate(newStage: ExperimentStage): DefaultBrowserPromptsExperimentStageAction +} + +DefaultBrowserPromptsExperimentStageEvaluator::targetCohort --> AdditionalPromptsCohortName : uses +DefaultBrowserPromptsExperimentStageEvaluator::evaluate --> ExperimentStage : accepts +DefaultBrowserPromptsExperimentStageEvaluator::evaluate --> DefaultBrowserPromptsExperimentStageAction : returns + +note top of DefaultBrowserPromptsExperimentStageEvaluator + For a given target cohort, the evaluator implementation returns actions that the experiment implementation should take next. + These actions include showing dialogs, adding elements to the menu, etc. + Evaluation only happens once per stage change, so it's up to the experiment impl to persist the state for actions like menu buttons/highlights, + for example when a button should be present in the menu for as long as a given stage is active, including across app launches. +end note + +class Variant2 + +DefaultBrowserPromptsExperimentStageEvaluator <|--- Variant2 : implements + +note left of Variant2 + Stage 1: show a pop-up dialog. + Stage 2: add an overflow menu button and highlight the menu itself with a blue dot. +end note + +class DefaultBrowserPromptsExperimentImpl { + - val appCoroutineScope: CoroutineScope + - val dispatchers: DispatcherProvider + - val applicationContext: Context + - val defaultBrowserPromptsFeatureToggles: DefaultBrowserPromptsFeatureToggles + - val defaultBrowserDetector: DefaultBrowserDetector + - val defaultRoleBrowserDialog: DefaultRoleBrowserDialog + - val appDaysUsedRepository: AppDaysUsedRepository + - val defaultBrowserPromptsDataStore: DefaultBrowserPromptsDataStore + - val experimentStageEvaluatorPluginPoint: PluginPoint + - val pixelSender: PixelSender + - val moshi: Moshi +} + +MainProcessLifecycleObserver <|--- DefaultBrowserPromptsExperimentImpl : implements + +DefaultBrowserPromptsExperiment <|-- DefaultBrowserPromptsExperimentImpl : implements + +DefaultBrowserPromptsExperimentImpl::defaultBrowserPromptsDataStore --> DefaultBrowserPromptsDataStore : uses + +DefaultBrowserPromptsExperimentImpl::defaultBrowserPromptsFeatureToggles --> DefaultBrowserPromptsFeatureToggles : uses + +DefaultBrowserPromptsExperimentImpl::experimentStageEvaluatorPluginPoint ---> DefaultBrowserPromptsExperimentStageEvaluator : uses + +note left of DefaultBrowserPromptsExperimentImpl::applicationContext + Used to create intents. +end note + +note left of DefaultBrowserPromptsExperimentImpl::defaultBrowserPromptsFeatureToggles + Used to manage cohorts and enrolment. +end note + +note left of DefaultBrowserPromptsExperimentImpl::defaultBrowserDetector + Used to check if the browser is set as default. +end note + +note left of DefaultBrowserPromptsExperimentImpl::defaultRoleBrowserDialog + Used to generate the intent to open the system's + default browser dialog. +end note + +note left of DefaultBrowserPromptsExperimentImpl::appDaysUsedRepository + Used to monitor the active use days. +end note + +note left of DefaultBrowserPromptsExperimentImpl::defaultBrowserPromptsDataStore + Used to persist the stage of the experiment. +end note + +note left of DefaultBrowserPromptsExperimentImpl::experimentStageEvaluatorPluginPoint + Used to inject the definitions of what each variant should do given the experiment's stage. +end note + +note left of DefaultBrowserPromptsExperimentImpl::PixelSender + Used to send pixels defined in [[https://app.asana.com/0/1208671518894266/1208774988133227/f this Asana task]]. +end note + +note left of DefaultBrowserPromptsExperimentImpl::moshi + Used to parse feature settings from remote config. +end note + +component BrowserViewModel +BrowserViewModel --> DefaultBrowserPromptsExperiment : uses +note top of BrowserViewModel + Monitors the experiment to open the message dialog + that encourages users to set the DDG app as default browser. +end note + +component OmnibarLayoutViewModel +OmnibarLayoutViewModel --> DefaultBrowserPromptsExperiment : uses +note top of OmnibarLayoutViewModel + Monitors the experiment to show + a blue highlight dot next to overflow menu three-dots. +end note + +component BrowserTabViewModel +BrowserTabViewModel --> DefaultBrowserPromptsExperiment : uses +note top of BrowserTabViewModel + Monitors the experiment to add + an overflow menu item to set browser as default. +end note + +@enduml \ No newline at end of file diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/diagrams/default_browser_prompts_experiment_variant_2_flow_diagram.puml b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/diagrams/default_browser_prompts_experiment_variant_2_flow_diagram.puml new file mode 100644 index 000000000000..796aae86d7b3 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/diagrams/default_browser_prompts_experiment_variant_2_flow_diagram.puml @@ -0,0 +1,94 @@ +@startuml +start +note + Flow run each time when user opens or comes back to the app. +end note +:App process resumed or remote config loaded; +if (Is enrolled OR does not have DDG set as default browser) then (yes) + if (Has user converted already?) then (no) + if (Is user in the `variant_2` cohort?) then (yes) + note + Also enrolls and assigns the cohort, if needed. + end note + if (Is DDG the default browser app) then (no) + switch (Experiment stage) + case (NOT_ENROLLED) + :Enroll and assign a cohort; + :Move stage to ENROLLED; + case (ENROLLED) + if (App active use days since enrollment >= 1?) then (yes) + :Move stage to STAGE_1; + :Show message dialog; + else (no) + endif + case (STAGE_1) + if (App active use days since enrollment >= 20?) then (yes) + :Move stage to STAGE_2; + :Show overflow menu highlight; + :Show overflow menu item; + else (no) + endif + case (STAGE_2) + if (App active use days since enrollment >= 30?) then (yes) + :Move stage to STOPPED; + :Remove overflow menu highlight; + :Remove overflow menu item; + else (no) + endif + case (STOPPED) + :noop; + case (CONVERTED) + :noop; + endswitch + stop + else (yes) + if (Is STOPPED) is (yes) then + else (no) + :Move stage to CONVERTED; + :Send conversion pixel; + endif + endif + else (no) + note right + If experiment was underway but we lost the cohort name, + it means that the experiment was remotely disabled. + end note + if (Was enrolled already?) is (yes) then + :Move stage to STOPPED; + :Remove overflow menu highlight; + :Remove overflow menu item; + else (no) + endif + endif + else (yes) + endif +else (false) +endif +stop + +start +note + Flow run each time user clicks the overflow menu button. +end note +:Overflow menu opened; +:Remove overflow menu highlight; +stop + +start +note + Flow run each time user clicks "Set as Default Browser" in the message dialog, + or when "Set as Default Browser" button in the overflow menu is clicked. +end note +:Message dialog's call-to-action accepted (primary button clicked); +:Open system's default browser selection dialog; +if (System's default browser selection dialog canceled in less than 500 ms?) then (yes) + :Open system's default apps settings activity; + note + Workaround in case user selected "Don't ask again" in the system dialog. + Details in [[https://app.asana.com/0/0/1208996977455495/f this Asana task]]. + end note +else (no) +endif +stop + +@enduml diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/store/DefaultBrowserPromptsDataStore.kt b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/store/DefaultBrowserPromptsDataStore.kt new file mode 100644 index 000000000000..76ff92186c94 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/store/DefaultBrowserPromptsDataStore.kt @@ -0,0 +1,91 @@ +/* + * 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.app.browser.defaultbrowsing.prompts.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.duckduckgo.app.browser.defaultbrowsing.prompts.di.DefaultBrowserPrompts +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.NOT_ENROLLED +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface DefaultBrowserPromptsDataStore { + val experimentStage: Flow + val showOverflowMenuItem: Flow + val highlightOverflowMenuIcon: Flow + + suspend fun storeExperimentStage(stage: ExperimentStage) + suspend fun storeShowOverflowMenuItemState(show: Boolean) + suspend fun storeHighlightOverflowMenuIconState(highlight: Boolean) + + enum class ExperimentStage { + NOT_ENROLLED, + ENROLLED, + STAGE_1, + STAGE_2, + STOPPED, + CONVERTED, + } +} + +@ContributesBinding(AppScope::class) +class DefaultBrowserPromptsPrefsDataStoreImpl @Inject constructor( + @DefaultBrowserPrompts private val store: DataStore, +) : DefaultBrowserPromptsDataStore { + companion object { + private const val PREF_KEY_EXPERIMENT_STAGE_ID = "additional_default_browser_prompts_experiment_stage_id" + private const val PREF_KEY_SHOW_OVERFLOW_MENU_ITEM = "additional_default_browser_prompts_show_overflow_menu_item" + private const val PREF_KEY_HIGHLIGHT_OVERFLOW_MENU_ICON = "additional_default_browser_prompts_highlight_overflow_menu_icon" + } + + override val experimentStage: Flow = store.data.map { preferences -> + preferences[stringPreferencesKey(PREF_KEY_EXPERIMENT_STAGE_ID)]?.let { ExperimentStage.valueOf(it) } ?: NOT_ENROLLED + } + + override val showOverflowMenuItem: Flow = store.data.map { preferences -> + preferences[booleanPreferencesKey(PREF_KEY_SHOW_OVERFLOW_MENU_ITEM)] ?: false + } + + override val highlightOverflowMenuIcon: Flow = store.data.map { preferences -> + preferences[booleanPreferencesKey(PREF_KEY_HIGHLIGHT_OVERFLOW_MENU_ICON)] ?: false + } + + override suspend fun storeExperimentStage(stage: ExperimentStage) { + store.edit { preferences -> + preferences[stringPreferencesKey(PREF_KEY_EXPERIMENT_STAGE_ID)] = stage.name + } + } + + override suspend fun storeShowOverflowMenuItemState(show: Boolean) { + store.edit { preferences -> + preferences[booleanPreferencesKey(PREF_KEY_SHOW_OVERFLOW_MENU_ITEM)] = show + } + } + + override suspend fun storeHighlightOverflowMenuIconState(highlight: Boolean) { + store.edit { preferences -> + preferences[booleanPreferencesKey(PREF_KEY_HIGHLIGHT_OVERFLOW_MENU_ICON)] = highlight + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/ui/DefaultBrowserBottomSheetDialog.kt b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/ui/DefaultBrowserBottomSheetDialog.kt index 08929fcdc2ab..9691254472d2 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/ui/DefaultBrowserBottomSheetDialog.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/ui/DefaultBrowserBottomSheetDialog.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.browser.defaultbrowsing.prompts.ui import android.annotation.SuppressLint import android.content.Context +import android.content.DialogInterface import android.view.LayoutInflater import android.widget.FrameLayout import com.duckduckgo.app.browser.databinding.BottomSheetDefaultBrowserBinding @@ -33,31 +34,51 @@ class DefaultBrowserBottomSheetDialog(private val context: Context) : BottomShee private val binding: BottomSheetDefaultBrowserBinding = BottomSheetDefaultBrowserBinding.inflate(LayoutInflater.from(context)) + var eventListener: EventListener? = null + init { setContentView(binding.root) // We need the dialog to always be expanded and not draggable because the content takes up a lot of vertical space and requires a scroll view, // especially in landscape aspect-ratios. If the dialog started as collapsed, the drag would interfere with internal scroll. this.behavior.state = BottomSheetBehavior.STATE_EXPANDED this.behavior.isDraggable = false - roundCornersAlways(this) + + setOnShowListener { dialogInterface -> + setRoundCorners(dialogInterface) + eventListener?.onShown() + } + setOnDismissListener { + eventListener?.onDismissed() + } + binding.defaultBrowserBottomSheetDialogPrimaryButton.setOnClickListener { + eventListener?.onSetBrowserButtonClicked() + } + binding.defaultBrowserBottomSheetDialogGhostButton.setOnClickListener { + eventListener?.onNotNowButtonClicked() + } } /** * By default, when bottom sheet dialog is expanded, the corners become squared. * This function ensures that the bottom sheet dialog will have rounded corners even when in an expanded state. */ - private fun roundCornersAlways(dialog: BottomSheetDialog) { - dialog.setOnShowListener { dialogInterface -> - val bottomSheetDialog = dialogInterface as BottomSheetDialog - val bottomSheet = bottomSheetDialog.findViewById(R.id.design_bottom_sheet) - - val shapeDrawable = MaterialShapeDrawable.createWithElevationOverlay(context) - shapeDrawable.shapeAppearanceModel = shapeDrawable.shapeAppearanceModel - .toBuilder() - .setTopLeftCorner(CornerFamily.ROUNDED, context.resources.getDimension(CommonR.dimen.dialogBorderRadius)) - .setTopRightCorner(CornerFamily.ROUNDED, context.resources.getDimension(CommonR.dimen.dialogBorderRadius)) - .build() - bottomSheet?.background = shapeDrawable - } + private fun setRoundCorners(dialogInterface: DialogInterface) { + val bottomSheetDialog = dialogInterface as BottomSheetDialog + val bottomSheet = bottomSheetDialog.findViewById(R.id.design_bottom_sheet) + + val shapeDrawable = MaterialShapeDrawable.createWithElevationOverlay(context) + shapeDrawable.shapeAppearanceModel = shapeDrawable.shapeAppearanceModel + .toBuilder() + .setTopLeftCorner(CornerFamily.ROUNDED, context.resources.getDimension(CommonR.dimen.dialogBorderRadius)) + .setTopRightCorner(CornerFamily.ROUNDED, context.resources.getDimension(CommonR.dimen.dialogBorderRadius)) + .build() + bottomSheet?.background = shapeDrawable + } + + interface EventListener { + fun onShown() + fun onDismissed() + fun onSetBrowserButtonClicked() + fun onNotNowButtonClicked() } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt index 2f9c5b14d5ac..5cca9f0827c3 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayout.kt @@ -149,6 +149,7 @@ class OmnibarLayout @JvmOverloads constructor( internal val tabsMenu: TabSwitcherButton by lazy { findViewById(R.id.tabsMenu) } internal val fireIconMenu: FrameLayout by lazy { findViewById(R.id.fireIconMenu) } internal val browserMenu: FrameLayout by lazy { findViewById(R.id.browserMenu) } + internal val browserMenuHighlight: View by lazy { findViewById(R.id.browserMenuHighlight) } internal val cookieDummyView: View by lazy { findViewById(R.id.cookieDummyView) } internal val cookieAnimation: LottieAnimationView by lazy { findViewById(R.id.cookieAnimation) } internal val sceneRoot: ViewGroup by lazy { findViewById(R.id.sceneRoot) } @@ -460,6 +461,7 @@ class OmnibarLayout @JvmOverloads constructor( tabsMenu.isVisible = viewState.showTabsMenu fireIconMenu.isVisible = viewState.showFireIcon browserMenu.isVisible = viewState.showBrowserMenu + browserMenuHighlight.isVisible = viewState.showBrowserMenuHighlight spacer.isVisible = viewState.showVoiceSearch && viewState.showClearButton } diff --git a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt index 364bd611eea8..f34a6b7bc85f 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModel.kt @@ -22,6 +22,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.browser.DuckDuckGoUrlDetector +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.Browser import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode.CustomTab @@ -60,6 +61,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -78,6 +80,7 @@ class OmnibarLayoutViewModel @Inject constructor( private val pixel: Pixel, private val userBrowserProperties: UserBrowserProperties, private val dispatcherProvider: DispatcherProvider, + private val defaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment, ) : ViewModel() { private val _viewState = MutableStateFlow(ViewState()) @@ -106,6 +109,7 @@ class OmnibarLayoutViewModel @Inject constructor( val showTabsMenu: Boolean = true, val showFireIcon: Boolean = true, val showBrowserMenu: Boolean = true, + val showBrowserMenuHighlight: Boolean = false, val scrollingEnabled: Boolean = true, val isLoading: Boolean = false, val loadingProgress: Int = 0, @@ -126,6 +130,16 @@ class OmnibarLayoutViewModel @Inject constructor( GLOBE, } + init { + viewModelScope.launch { + defaultBrowserPromptsExperiment.highlightOverflowMenu.collect { highlightOverflowMenu -> + _viewState.update { + it.copy(showBrowserMenuHighlight = highlightOverflowMenu) + } + } + } + } + fun onAttachedToWindow() { tabRepository.flowTabs .onEach { tabs -> diff --git a/app/src/main/res/drawable/ic_circle_7_accent_blue.xml b/app/src/main/res/drawable/ic_circle_7_accent_blue.xml new file mode 100644 index 000000000000..059324bbc9a3 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle_7_accent_blue.xml @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_new_omnibar.xml b/app/src/main/res/layout/view_new_omnibar.xml index 8e62039ac9e7..72b32d2621e0 100644 --- a/app/src/main/res/layout/view_new_omnibar.xml +++ b/app/src/main/res/layout/view_new_omnibar.xml @@ -311,6 +311,14 @@ android:contentDescription="@string/browserPopupMenu" android:src="@drawable/ic_menu_vertical_24" /> + + + + (capacity = Channel.CONFLATED) + + @Mock private lateinit var mockDefaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment + private val fakeShowOnAppLaunchFeatureToggle = FakeFeatureToggleFactory.create(ShowOnAppLaunchFeature::class.java) private lateinit var testee: BrowserViewModel @@ -95,6 +104,8 @@ class BrowserViewModelTest { configureSkipUrlConversionInNewTabState(enabled = true) + whenever(mockDefaultBrowserPromptsExperiment.commands).thenReturn(defaultBrowserPromptsExperimentCommandsFlow.receiveAsFlow()) + initTestee() testee.command.observeForever(mockCommandObserver) @@ -286,6 +297,99 @@ class BrowserViewModelTest { verify(showOnAppLaunchOptionHandler).handleAppLaunchOption() } + @Test + fun `when default browser prompts experiment OpenMessageDialog command, then propagate it to consumers`() = runTest { + defaultBrowserPromptsExperimentCommandsFlow.send(DefaultBrowserPromptsExperiment.Command.OpenMessageDialog) + + verify(mockCommandObserver).onChanged(commandCaptor.capture()) + assertEquals(Command.ShowSetAsDefaultBrowserDialog, commandCaptor.lastValue) + } + + @Test + fun `when default browser prompts experiment OpenSystemDefaultBrowserDialog command, then propagate it to consumers`() = runTest { + val intent: Intent = mock() + defaultBrowserPromptsExperimentCommandsFlow.send(DefaultBrowserPromptsExperiment.Command.OpenSystemDefaultBrowserDialog(intent)) + + verify(mockCommandObserver).onChanged(commandCaptor.capture()) + assertEquals(Command.ShowSystemDefaultBrowserDialog(intent), commandCaptor.lastValue) + } + + @Test + fun `when default browser prompts experiment OpenSystemDefaultAppsActivity command, then propagate it to consumers`() = runTest { + val intent: Intent = mock() + defaultBrowserPromptsExperimentCommandsFlow.send(DefaultBrowserPromptsExperiment.Command.OpenSystemDefaultAppsActivity(intent)) + + verify(mockCommandObserver).onChanged(commandCaptor.capture()) + assertEquals(Command.ShowSystemDefaultAppsActivity(intent), commandCaptor.lastValue) + } + + @Test + fun `when onSetDefaultBrowserDialogShown called, then pass that information to the experiment`() { + testee.onSetDefaultBrowserDialogShown() + + verify(mockDefaultBrowserPromptsExperiment).onMessageDialogShown() + } + + @Test + fun `when onSetDefaultBrowserDismissed called, then pass that information to the experiment`() { + testee.onSetDefaultBrowserDismissed() + + verify(mockDefaultBrowserPromptsExperiment).onMessageDialogDismissed() + } + + @Test + fun `when onSetDefaultBrowserConfirmationButtonClicked called, then pass that information to the experiment`() { + testee.onSetDefaultBrowserConfirmationButtonClicked() + + verify(mockDefaultBrowserPromptsExperiment).onMessageDialogConfirmationButtonClicked() + verify(mockCommandObserver).onChanged(commandCaptor.capture()) + assertEquals(Command.HideSetAsDefaultBrowserDialog, commandCaptor.lastValue) + } + + @Test + fun `when onSetDefaultBrowserNotNowButtonClicked called, then pass that information to the experiment`() { + testee.onSetDefaultBrowserNotNowButtonClicked() + + verify(mockDefaultBrowserPromptsExperiment).onMessageDialogNotNowButtonClicked() + verify(mockCommandObserver).onChanged(commandCaptor.capture()) + assertEquals(Command.HideSetAsDefaultBrowserDialog, commandCaptor.lastValue) + } + + @Test + fun `when onSystemDefaultBrowserDialogShown called, then pass that information to the experiment`() { + testee.onSystemDefaultBrowserDialogShown() + + verify(mockDefaultBrowserPromptsExperiment).onSystemDefaultBrowserDialogShown() + } + + @Test + fun `when onSystemDefaultBrowserDialogSuccess called, then pass that information to the experiment`() { + testee.onSystemDefaultBrowserDialogSuccess() + + verify(mockDefaultBrowserPromptsExperiment).onSystemDefaultBrowserDialogSuccess() + } + + @Test + fun `when onSystemDefaultBrowserDialogCanceled called, then pass that information to the experiment`() { + testee.onSystemDefaultBrowserDialogCanceled() + + verify(mockDefaultBrowserPromptsExperiment).onSystemDefaultBrowserDialogCanceled() + } + + @Test + fun `when onSystemDefaultAppsActivityOpened called, then pass that information to the experiment`() { + testee.onSystemDefaultAppsActivityOpened() + + verify(mockDefaultBrowserPromptsExperiment).onSystemDefaultAppsActivityOpened() + } + + @Test + fun `when onSystemDefaultAppsActivityClosed called, then pass that information to the experiment`() { + testee.onSystemDefaultAppsActivityClosed() + + verify(mockDefaultBrowserPromptsExperiment).onSystemDefaultAppsActivityClosed() + } + private fun initTestee() { testee = BrowserViewModel( tabRepository = mockTabRepository, @@ -299,6 +403,7 @@ class BrowserViewModelTest { skipUrlConversionOnNewTabFeature = skipUrlConversionOnNewTabFeature, showOnAppLaunchFeature = fakeShowOnAppLaunchFeatureToggle, showOnAppLaunchOptionHandler = showOnAppLaunchOptionHandler, + defaultBrowserPromptsExperiment = mockDefaultBrowserPromptsExperiment, ) } diff --git a/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentImplTest.kt b/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentImplTest.kt new file mode 100644 index 000000000000..ee2aa2bc4424 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/defaultbrowsing/prompts/DefaultBrowserPromptsExperimentImplTest.kt @@ -0,0 +1,844 @@ +/* + * Copyright (c) 2025 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.app.browser.defaultbrowsing.prompts + +import android.content.Context +import android.content.Intent +import androidx.lifecycle.LifecycleOwner +import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserDetector +import com.duckduckgo.app.browser.defaultbrowsing.DefaultBrowserSystemSettings +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment.Command +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperimentImpl.Companion.FALLBACK_TO_DEFAULT_APPS_SCREEN_THRESHOLD_MILLIS +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperimentImpl.FeatureSettings +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsFeatureToggles.AdditionalPromptsCohortName +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore +import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage +import com.duckduckgo.app.global.DefaultRoleBrowserDialog +import com.duckduckgo.app.usage.app.AppDaysUsedRepository +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.plugins.PluginPoint +import com.duckduckgo.feature.toggles.api.Toggle +import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import java.time.Instant +import java.util.Date +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.MockitoAnnotations +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.mockito.kotlin.spy +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultBrowserPromptsExperimentImplTest { + + @get:Rule + var coroutinesTestRule = CoroutineTestRule() + + @Mock private lateinit var appContextMock: Context + + @Mock private lateinit var lifecycleOwnerMock: LifecycleOwner + + @Mock private lateinit var featureTogglesMock: DefaultBrowserPromptsFeatureToggles + + @Mock private lateinit var additionalPromptsToggleMock: Toggle + + private val additionalPromptsFeatureSettingsFake = "fake feature settings JSON" + + @Mock private lateinit var defaultRoleBrowserDialogMock: DefaultRoleBrowserDialog + + @Mock private lateinit var defaultBrowserDetectorMock: DefaultBrowserDetector + + @Mock private lateinit var appDaysUsedRepositoryMock: AppDaysUsedRepository + + @Mock private lateinit var experimentStageEvaluatorPluginPointMock: PluginPoint + + @Mock private lateinit var moshiMock: Moshi + + @Mock private lateinit var featureSettingsJsonAdapterMock: JsonAdapter + + @Mock private lateinit var systemDefaultBrowserDialogIntentMock: Intent + + private lateinit var dataStoreMock: DefaultBrowserPromptsDataStore + + private val fakeEnrollmentDateETString = "2025-01-16T00:00-05:00[America/New_York]" + private val fakeEnrollmentDate = Date.from(Instant.ofEpochMilli(1737003600000)) + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + dataStoreMock = createDataStoreFake() + + whenever(featureTogglesMock.defaultBrowserAdditionalPrompts202501()).thenReturn(additionalPromptsToggleMock) + whenever(defaultRoleBrowserDialogMock.createIntent(appContextMock)).thenReturn(systemDefaultBrowserDialogIntentMock) + whenever(moshiMock.adapter(any())).thenReturn(featureSettingsJsonAdapterMock) + } + + @Test + fun `when initialized, then don't highlight overflow menu`() = runTest { + val testee = createTestee() + assertFalse(testee.highlightOverflowMenu.first()) + } + + @Test + fun `when initialized, then don't show overflow menu`() = runTest { + val testee = createTestee() + assertFalse(testee.showOverflowMenuItem.first()) + } + + @Test + fun `when overflow menu opened, then remove the highlight`() = runTest { + val dataStoreMock = createDataStoreFake( + initialHighlightOverflowMenuIcon = true, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + val expectedUpdates = listOf( + false, // initial impl value + true, // initial data store value + false, // update + ) + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.highlightOverflowMenu.toList(actualUpdates) + } + assertEquals(2, actualUpdates.size) // initial values expected immediately + + testee.onOverflowMenuOpened() + + assertEquals(expectedUpdates, actualUpdates) + } + + @Test + fun `when overflow menu item clicked, then launch browser selection system dialog`() = runTest { + val testee = createTestee() + val expectedUpdates = listOf( + Command.OpenSystemDefaultBrowserDialog(systemDefaultBrowserDialogIntentMock), + ) + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.commands.toList(actualUpdates) + } + assertTrue(actualUpdates.isEmpty()) + + testee.onOverflowMenuItemClicked() + + assertEquals(expectedUpdates, actualUpdates) + } + + /** + * fixme to verify that the correct intent is passed, + * we need to refactor [DefaultBrowserSystemSettings] to not use a companion object function, + * or use a different test lib to mock the companion object function + */ + @Test + fun `when overflow menu item clicked and dialog fails, then launch default apps screen as fallback`() = runTest { + val testee = createTestee() + whenever(defaultRoleBrowserDialogMock.createIntent(appContextMock)).thenReturn(null) + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.commands.toList(actualUpdates) + } + assertTrue(actualUpdates.isEmpty()) + + testee.onOverflowMenuItemClicked() + + assertEquals(1, actualUpdates.size) + assertTrue(actualUpdates[0] is Command.OpenSystemDefaultAppsActivity) + } + + @Test + fun `when message dialog confirmation clicked, then launch browser selection system dialog`() = runTest { + val testee = createTestee() + val expectedUpdates = listOf( + Command.OpenSystemDefaultBrowserDialog(systemDefaultBrowserDialogIntentMock), + ) + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.commands.toList(actualUpdates) + } + assertTrue(actualUpdates.isEmpty()) + + testee.onMessageDialogConfirmationButtonClicked() + + assertEquals(expectedUpdates, actualUpdates) + } + + /** + * fixme to verify that the correct intent is passed, + * we need to refactor [DefaultBrowserSystemSettings] to not use a companion object function, + * or use a different test lib to mock the companion object function + */ + @Test + fun `when message dialog confirmation clicked and dialog fails, then launch default apps screen as fallback`() = runTest { + val testee = createTestee() + whenever(defaultRoleBrowserDialogMock.createIntent(appContextMock)).thenReturn(null) + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.commands.toList(actualUpdates) + } + assertTrue(actualUpdates.isEmpty()) + + testee.onMessageDialogConfirmationButtonClicked() + + assertEquals(1, actualUpdates.size) + assertTrue(actualUpdates[0] is Command.OpenSystemDefaultAppsActivity) + } + + /** + * Details in this [Asana task](https://app.asana.com/0/0/1208996977455495/f). + * + * fixme to verify that the correct intent is passed, + * we need to refactor [DefaultBrowserSystemSettings] to not use a companion object function, + * or use a different test lib to mock the companion object function + */ + @Test + fun `when system default browser dialog canceled quickly, then open default apps screen instead`() = runTest { + val testee = createTestee() + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.commands.toList(actualUpdates) + } + assertTrue(actualUpdates.isEmpty()) + + testee.onSystemDefaultBrowserDialogShown() + advanceTimeBy(FALLBACK_TO_DEFAULT_APPS_SCREEN_THRESHOLD_MILLIS - 1) // canceled before threshold + testee.onSystemDefaultBrowserDialogCanceled() + testee.onSystemDefaultBrowserDialogCanceled() // verifies that repeated cancellation won't keep opening new screens + + assertEquals(1, actualUpdates.size) + assertTrue(actualUpdates[0] is Command.OpenSystemDefaultAppsActivity) + } + + @Test + fun `when system default browser dialog is not canceled quickly, then do nothing`() = runTest { + val testee = createTestee() + val actualUpdates = mutableListOf() + coroutinesTestRule.testScope.launch { + testee.commands.toList(actualUpdates) + } + assertTrue(actualUpdates.isEmpty()) + + testee.onSystemDefaultBrowserDialogShown() + advanceTimeBy(FALLBACK_TO_DEFAULT_APPS_SCREEN_THRESHOLD_MILLIS + 1) // canceled after threshold + testee.onSystemDefaultBrowserDialogCanceled() + + assertTrue(actualUpdates.isEmpty()) + } + + @Test + fun `evaluate - if not enrolled and browser already set as default, then don't enroll`() = runTest { + val testee = createTestee() + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(true) + whenever(additionalPromptsToggleMock.getCohort()).thenReturn(null) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock, never()).storeExperimentStage(any()) + assertEquals(ExperimentStage.NOT_ENROLLED, dataStoreMock.experimentStage.first()) + } + + @Test + fun `evaluate - if not enrolled, browser not set as default, but no cohort assigned, then don't enroll`() = runTest { + val testee = createTestee() + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + whenever(additionalPromptsToggleMock.getCohort()).thenReturn(null) + whenever(additionalPromptsToggleMock.isEnabled(any())).thenReturn(false) + + testee.onResume(lifecycleOwnerMock) + + AdditionalPromptsCohortName.entries.forEach { + verify(additionalPromptsToggleMock).isEnabled(it) + } + verify(dataStoreMock, never()).storeExperimentStage(any()) + assertEquals(ExperimentStage.NOT_ENROLLED, dataStoreMock.experimentStage.first()) + } + + @Test + fun `evaluate - if not enrolled, browser not set as default, and cohort assigned, then enroll`() = runTest { + val testee = createTestee() + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.ENROLLED, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock).storeExperimentStage(ExperimentStage.ENROLLED) + verify(evaluatorMock).evaluate(ExperimentStage.ENROLLED) + } + + @Test + fun `evaluate - if enrolled, browser not set as default, and not enough active days until stage 1, then do nothing`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.ENROLLED, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(0) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.ENROLLED, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = mock(), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock, never()).storeExperimentStage(any()) + verify(evaluatorMock, never()).evaluate(any()) + } + + @Test + fun `evaluate - if enrolled, browser not set as default, and enough active days until stage 1, then move to stage 1`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.ENROLLED, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(1) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.STAGE_1, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = true, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock).storeExperimentStage(ExperimentStage.STAGE_1) + verify(evaluatorMock).evaluate(ExperimentStage.STAGE_1) + } + + @Test + fun `evaluate - if enrolled, browser not set as default, and not enough active days until stage 2, then do nothing`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STAGE_1, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(2) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.STAGE_1, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = mock(), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock, never()).storeExperimentStage(any()) + verify(evaluatorMock, never()).evaluate(any()) + } + + @Test + fun `evaluate - if enrolled, browser not set as default, and enough active days until stage 2, then move to stage 2`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STAGE_1, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(3) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.STAGE_2, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = true, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock).storeExperimentStage(ExperimentStage.STAGE_2) + verify(evaluatorMock).evaluate(ExperimentStage.STAGE_2) + } + + @Test + fun `evaluate - if enrolled, browser not set as default, and not enough active days until stop, then do nothing`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STAGE_2, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(4) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.STAGE_2, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = mock(), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock, never()).storeExperimentStage(any()) + verify(evaluatorMock, never()).evaluate(any()) + } + + @Test + fun `evaluate - if enrolled, browser not set as default, and enough active days until stop, then move to stopped`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STAGE_2, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(5) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.STOPPED, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = true, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock).storeExperimentStage(ExperimentStage.STOPPED) + verify(evaluatorMock).evaluate(ExperimentStage.STOPPED) + } + + @Test + fun `evaluate - if enrolled and browser set as default, then convert`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.ENROLLED, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(true) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.CONVERTED, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock).storeExperimentStage(ExperimentStage.CONVERTED) + verify(evaluatorMock).evaluate(ExperimentStage.CONVERTED) + } + + @Test + fun `evaluate - if stage 1 and browser set as default, then convert`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STAGE_1, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(true) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.CONVERTED, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock).storeExperimentStage(ExperimentStage.CONVERTED) + verify(evaluatorMock).evaluate(ExperimentStage.CONVERTED) + } + + @Test + fun `evaluate - if stage 2 and browser set as default, then convert`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STAGE_2, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(true) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.CONVERTED, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock).storeExperimentStage(ExperimentStage.CONVERTED) + verify(evaluatorMock).evaluate(ExperimentStage.CONVERTED) + } + + @Test + fun `evaluate - if stopped and browser set as default, then don't convert`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STOPPED, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(true) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.CONVERTED, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock, never()).storeExperimentStage(any()) + verify(evaluatorMock, never()).evaluate(any()) + } + + @Test + fun `evaluate - if converted and browser set as default, then don't convert again`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.CONVERTED, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(true) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.CONVERTED, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + + verify(dataStoreMock, never()).storeExperimentStage(any()) + verify(evaluatorMock, never()).evaluate(any()) + } + + @Test + fun `evaluate - if stage changes and show dialog action produced, then propagate it`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.ENROLLED, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(1) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.STAGE_1, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = true, + showOverflowMenuItem = false, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + val command = testee.commands.first() + + assertEquals(Command.OpenMessageDialog, command) + } + + @Test + fun `evaluate - if stage changes and show menu item action produced, then propagate it`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STAGE_1, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort(cohortName = AdditionalPromptsCohortName.VARIANT_2) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(3) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.STAGE_2, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = true, + highlightOverflowMenu = false, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + val result = testee.showOverflowMenuItem.first() + + assertTrue(result) + verify(dataStoreMock).storeShowOverflowMenuItemState(show = true) + } + + @Test + fun `evaluate - if stage changes and highlight menu action produced, then propagate it`() = runTest { + val dataStoreMock = createDataStoreFake( + initialExperimentStage = ExperimentStage.STAGE_1, + ) + val testee = createTestee( + defaultBrowserPromptsDataStore = dataStoreMock, + ) + whenever(defaultBrowserDetectorMock.isDefaultBrowser()).thenReturn(false) + mockActiveCohort( + cohortName = AdditionalPromptsCohortName.VARIANT_2, + ) + mockFeatureSettings( + activeDaysUntilStage1 = 1, + activeDaysUntilStage2 = 3, + activeDaysUntilStop = 5, + ) + whenever(appDaysUsedRepositoryMock.getNumberOfDaysAppUsedSinceDate(fakeEnrollmentDate)).thenReturn(3) + val evaluatorMock = mockStageEvaluator( + forNewStage = ExperimentStage.STAGE_2, + forCohortName = AdditionalPromptsCohortName.VARIANT_2, + returnsAction = DefaultBrowserPromptsExperimentStageAction( + showMessageDialog = false, + showOverflowMenuItem = false, + highlightOverflowMenu = true, + ), + ) + whenever(experimentStageEvaluatorPluginPointMock.getPlugins()).thenReturn(setOf(evaluatorMock)) + + testee.onResume(lifecycleOwnerMock) + val result = testee.highlightOverflowMenu.first() + + assertTrue(result) + verify(dataStoreMock).storeHighlightOverflowMenuIconState(highlight = true) + } + + private fun createTestee( + appCoroutineScope: CoroutineScope = coroutinesTestRule.testScope, + dispatchers: DispatcherProvider = coroutinesTestRule.testDispatcherProvider, + applicationContext: Context = appContextMock, + defaultBrowserPromptsFeatureToggles: DefaultBrowserPromptsFeatureToggles = featureTogglesMock, + defaultBrowserDetector: DefaultBrowserDetector = defaultBrowserDetectorMock, + defaultRoleBrowserDialog: DefaultRoleBrowserDialog = defaultRoleBrowserDialogMock, + appDaysUsedRepository: AppDaysUsedRepository = appDaysUsedRepositoryMock, + defaultBrowserPromptsDataStore: DefaultBrowserPromptsDataStore = dataStoreMock, + experimentStageEvaluatorPluginPoint: PluginPoint = experimentStageEvaluatorPluginPointMock, + moshi: Moshi = moshiMock, + ) = DefaultBrowserPromptsExperimentImpl( + appCoroutineScope = appCoroutineScope, + dispatchers = dispatchers, + applicationContext = applicationContext, + defaultBrowserPromptsFeatureToggles = defaultBrowserPromptsFeatureToggles, + defaultBrowserDetector = defaultBrowserDetector, + defaultRoleBrowserDialog = defaultRoleBrowserDialog, + appDaysUsedRepository = appDaysUsedRepository, + defaultBrowserPromptsDataStore = defaultBrowserPromptsDataStore, + experimentStageEvaluatorPluginPoint = experimentStageEvaluatorPluginPoint, + moshi = moshi, + ) + + private fun createDataStoreFake( + initialExperimentStage: ExperimentStage = ExperimentStage.NOT_ENROLLED, + initialShowOverflowMenuItem: Boolean = false, + initialHighlightOverflowMenuIcon: Boolean = false, + ) = spy( + DefaultBrowserPromptsDataStoreMock( + initialExperimentStage, + initialShowOverflowMenuItem, + initialHighlightOverflowMenuIcon, + ), + ) + + private fun mockActiveCohort(cohortName: AdditionalPromptsCohortName): Cohort { + val cohort = Cohort( + name = cohortName.name, + weight = 1, + enrollmentDateET = fakeEnrollmentDateETString, + ) + whenever(additionalPromptsToggleMock.getCohort()).thenReturn(cohort) + whenever(additionalPromptsToggleMock.isEnabled(cohortName)).thenReturn(true) + + return cohort + } + + private fun mockFeatureSettings( + activeDaysUntilStage1: Int, + activeDaysUntilStage2: Int, + activeDaysUntilStop: Int, + ): FeatureSettings { + val settings = FeatureSettings( + activeDaysUntilStage1 = activeDaysUntilStage1, + activeDaysUntilStage2 = activeDaysUntilStage2, + activeDaysUntilStop = activeDaysUntilStop, + ) + whenever(additionalPromptsToggleMock.getSettings()).thenReturn(additionalPromptsFeatureSettingsFake) + whenever(featureSettingsJsonAdapterMock.fromJson(additionalPromptsFeatureSettingsFake)).thenReturn(settings) + return settings + } + + private suspend fun mockStageEvaluator( + forNewStage: ExperimentStage, + forCohortName: AdditionalPromptsCohortName, + returnsAction: DefaultBrowserPromptsExperimentStageAction, + ): DefaultBrowserPromptsExperimentStageEvaluator { + val evaluatorMock: DefaultBrowserPromptsExperimentStageEvaluator = mock() + whenever(evaluatorMock.targetCohort).thenReturn(forCohortName) + whenever(evaluatorMock.evaluate(forNewStage)).thenReturn(returnsAction) + return evaluatorMock + } +} + +class DefaultBrowserPromptsDataStoreMock( + initialExperimentStage: ExperimentStage, + initialShowOverflowMenuItem: Boolean, + initialHighlightOverflowMenuIcon: Boolean, +) : DefaultBrowserPromptsDataStore { + + private val _experimentStage = MutableStateFlow(initialExperimentStage) + override val experimentStage: Flow = _experimentStage.asStateFlow() + + private val _showOverflowMenuItem = MutableStateFlow(initialShowOverflowMenuItem) + override val showOverflowMenuItem: Flow = _showOverflowMenuItem.asStateFlow() + + private val _highlightOverflowMenuIcon = MutableStateFlow(initialHighlightOverflowMenuIcon) + override val highlightOverflowMenuIcon: Flow = _highlightOverflowMenuIcon.asStateFlow() + + override suspend fun storeExperimentStage(stage: ExperimentStage) { + _experimentStage.value = stage + } + + override suspend fun storeShowOverflowMenuItemState(show: Boolean) { + _showOverflowMenuItem.value = show + } + + override suspend fun storeHighlightOverflowMenuIconState(highlight: Boolean) { + _highlightOverflowMenuIcon.value = highlight + } +} diff --git a/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt index 906e4e78e1d8..63843721bb1c 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/omnibar/OmnibarLayoutViewModelTest.kt @@ -4,6 +4,7 @@ import android.view.MotionEvent import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import com.duckduckgo.app.browser.DuckDuckGoUrlDetectorImpl +import com.duckduckgo.app.browser.defaultbrowsing.prompts.DefaultBrowserPromptsExperiment import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode import com.duckduckgo.app.browser.omnibar.OmnibarLayout.Decoration import com.duckduckgo.app.browser.omnibar.OmnibarLayout.StateChange @@ -31,6 +32,7 @@ import com.duckduckgo.privacy.dashboard.impl.pixels.PrivacyDashboardPixels import com.duckduckgo.voice.api.VoiceSearchAvailability import com.duckduckgo.voice.api.VoiceSearchAvailabilityPixelLogger import kotlin.reflect.KClass +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runTest import org.junit.Assert.* @@ -58,6 +60,9 @@ class OmnibarLayoutViewModelTest { private val pixel: Pixel = mock() private val userBrowserProperties: UserBrowserProperties = mock() + private val defaultBrowserPromptsExperimentHighlightOverflowMenuFlow = MutableStateFlow(false) + private val defaultBrowserPromptsExperiment: DefaultBrowserPromptsExperiment = mock() + private lateinit var testee: OmnibarLayoutViewModel private val EMPTY_URL = "" @@ -68,6 +73,8 @@ class OmnibarLayoutViewModelTest { @Before fun before() { + whenever(defaultBrowserPromptsExperiment.highlightOverflowMenu).thenReturn(defaultBrowserPromptsExperimentHighlightOverflowMenuFlow) + testee = OmnibarLayoutViewModel( tabRepository = tabRepository, voiceSearchAvailability = voiceSearchAvailability, @@ -77,6 +84,7 @@ class OmnibarLayoutViewModelTest { pixel = pixel, userBrowserProperties = userBrowserProperties, dispatcherProvider = coroutineTestRule.testDispatcherProvider, + defaultBrowserPromptsExperiment = defaultBrowserPromptsExperiment, ) whenever(tabRepository.flowTabs).thenReturn(flowOf(emptyList())) @@ -905,6 +913,16 @@ class OmnibarLayoutViewModelTest { } } + @Test + fun `when default browser experiment updates browser menu highlight, then update the view state`() = runTest { + defaultBrowserPromptsExperimentHighlightOverflowMenuFlow.value = true + + testee.viewState.test { + val viewState = awaitItem() + assertTrue(viewState.showBrowserMenuHighlight) + } + } + private fun givenSiteLoaded(loadedUrl: String) { testee.onViewModeChanged(ViewMode.Browser(loadedUrl)) testee.onExternalStateChange(