Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Additional "default browser" prompts: variant 2 #5476

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -672,6 +677,7 @@ class BrowserTabViewModelTest {
toggleReports = mockToggleReports,
brokenSitePrompt = mockBrokenSitePrompt,
tabStatsBucketing = mockTabStatsBucketing,
defaultBrowserPromptsExperiment = mockDefaultBrowserPromptsExperiment,
)

testee.loadData("abc", null, false, false)
Expand Down Expand Up @@ -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+"
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Preferences>

@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<ExperimentStage>()
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<Boolean>()
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<Boolean>()
coroutinesTestRule.testScope.launch {
testee.highlightOverflowMenuIcon.toList(actualUpdates)
}

testee.storeHighlightOverflowMenuIconState(highlight = true)

assertEquals(expectedUpdates, actualUpdates)
}
}
81 changes: 79 additions & 2 deletions app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -178,6 +200,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
binding.bottomMockupToolbar.appBarLayoutMockup.gone()
binding.topMockupToolbar
}

BOTTOM -> {
binding.topMockupToolbar.appBarLayoutMockup.gone()
binding.bottomMockupToolbar
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -713,6 +743,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
override fun onDialogShown() {
viewModel.onAppRatingDialogShown(promptCount)
}

override fun onDialogCancelled() {
viewModel.onUserCancelledRateAppDialog(promptCount)
}
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -2428,7 +2436,7 @@ class BrowserTabViewModel @Inject constructor(
}

fun onSetDefaultBrowserSelected() {
// no-op, to be implemented
defaultBrowserPromptsExperiment.onOverflowMenuItemClicked()
}

fun onShareSelected() {
Expand Down Expand Up @@ -2540,6 +2548,10 @@ class BrowserTabViewModel @Inject constructor(
}
}

fun onPopupMenuLaunched() {
defaultBrowserPromptsExperiment.onOverflowMenuOpened()
}

fun userRequestedOpeningNewTab(
longPress: Boolean = false,
) {
Expand Down
Loading
Loading