From bc04b7b63164d83e6aa651df3bfb77c6d964537d Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 25 Nov 2025 09:15:14 +0000 Subject: [PATCH 01/11] Add Input Screen onboarding step --- .../NewAddressBarOptionManager.kt | 10 +- .../OnboardingInputScreenSelectionObserver.kt | 58 ++++++++++ .../app/onboarding/store/OnboardingStore.kt | 2 + .../onboarding/store/OnboardingStoreImpl.kt | 13 +++ .../app/onboarding/ui/page/BbWelcomePage.kt | 7 ++ .../app/onboarding/ui/page/BuckWelcomePage.kt | 7 ++ .../ui/page/PreOnboardingDialogType.kt | 1 + .../app/onboarding/ui/page/WelcomePage.kt | 90 ++++++++++++++- .../ui/page/WelcomePageViewModel.kt | 24 ++++ .../layout/pre_onboarding_dax_dialog_cta.xml | 103 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + .../com/duckduckgo/duckchat/api/DuckChat.kt | 5 + .../duckduckgo/duckchat/impl/RealDuckChat.kt | 5 - 13 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt diff --git a/app/src/main/java/com/duckduckgo/app/browser/newaddressbaroption/NewAddressBarOptionManager.kt b/app/src/main/java/com/duckduckgo/app/browser/newaddressbaroption/NewAddressBarOptionManager.kt index b6aa5a46d6fc..d7e2f58fa7a0 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/newaddressbaroption/NewAddressBarOptionManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/newaddressbaroption/NewAddressBarOptionManager.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.browser.newaddressbaroption import android.app.Activity import com.duckduckgo.app.browser.omnibar.OmnibarType import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.common.ui.DuckDuckGoActivity @@ -55,6 +56,7 @@ class RealNewAddressBarOptionManager @Inject constructor( private val remoteMessagingRepository: RemoteMessagingRepository, private val newAddressBarOptionDataStore: NewAddressBarOptionDataStore, private val settingsDataStore: SettingsDataStore, + private val onboardingStore: OnboardingStore, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider(), ) : NewAddressBarOptionManager { private val showChoiceScreenMutex = Mutex() @@ -97,7 +99,8 @@ class RealNewAddressBarOptionManager @Inject constructor( hasNotInteractedWithSearchAndDuckAiRMF() && isNewAddressBarOptionChoiceScreenEnabled() && isNotLaunchedFromExternal(isLaunchedFromExternal) && - isSubsequentLaunch() + isSubsequentLaunch() && + hasNoInputScreenSelection() } private fun isActivityValid(activity: Activity): Boolean = @@ -130,6 +133,11 @@ class RealNewAddressBarOptionManager @Inject constructor( logcat(DEBUG) { "NewAddressBarOptionManager: $it isInputScreenDisabled" } } + private fun hasNoInputScreenSelection(): Boolean = + (onboardingStore.getInputScreenSelection() == null).also { + logcat(DEBUG) { "NewAddressBarOptionManager: $it hasNoInputScreenSelection" } + } + private fun isBottomAddressBarDisabled(): Boolean = (settingsDataStore.omnibarType != OmnibarType.SINGLE_BOTTOM).also { logcat(DEBUG) { "NewAddressBarOptionManager: $it isBottomAddressBarDisabled" } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt new file mode 100644 index 000000000000..9901ac3adafb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt @@ -0,0 +1,58 @@ +/* + * 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.onboarding + +import com.duckduckgo.app.di.AppCoroutineScope +import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver +import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.OnboardingStore +import com.duckduckgo.app.onboarding.store.UserStageStore +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.api.DuckChat +import com.squareup.anvil.annotations.ContributesMultibinding +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import javax.inject.Inject + +@ContributesMultibinding( + scope = AppScope::class, + boundType = MainProcessLifecycleObserver::class, +) +class OnboardingInputScreenSelectionObserver @Inject constructor( + @AppCoroutineScope private val appCoroutineScope: CoroutineScope, + dispatchers: DispatcherProvider, + private val userStageStore: UserStageStore, + private val onboardingStore: OnboardingStore, + private val duckChat: DuckChat, +) : MainProcessLifecycleObserver { + + init { + appCoroutineScope.launch(dispatchers.io()) { + userStageStore.userAppStageFlow() + .distinctUntilChanged() + .filter { it == AppStage.ESTABLISHED } + .collect { + onboardingStore.getInputScreenSelection()?.let { selection -> + duckChat.setInputScreenUserSetting(selection) + } + } + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt index b461ef5a7541..3bf23addb746 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt @@ -23,4 +23,6 @@ interface OnboardingStore { fun getSearchOptions(): List fun getSitesOptions(): List + fun storeInputScreenSelection(selected: Boolean) + fun getInputScreenSelection(): Boolean? } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt index 8433641fe226..e610313ecc82 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt @@ -163,8 +163,21 @@ class OnboardingStoreImpl @Inject constructor( ) } + override fun storeInputScreenSelection(selected: Boolean) { + preferences.edit { putBoolean(KEY_INPUT_SCREEN_SELECTION, selected) } + } + + override fun getInputScreenSelection(): Boolean? { + return if (preferences.contains(KEY_INPUT_SCREEN_SELECTION)) { + preferences.getBoolean(KEY_INPUT_SCREEN_SELECTION, false) + } else { + null + } + } + companion object { const val FILENAME = "com.duckduckgo.app.onboarding.settings" const val ONBOARDING_JOURNEY = "onboardingJourney" + private const val KEY_INPUT_SCREEN_SELECTION = "inputScreenSelection" } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BbWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BbWelcomePage.kt index d3edda420799..623d178f3604 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BbWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BbWelcomePage.kt @@ -57,8 +57,10 @@ import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.ADDRESS_BAR import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.COMPARISON_CHART import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL_REINSTALL_USER +import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INPUT_SCREEN import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.SKIP_ONBOARDING_OPTION import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.* +import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowInputScreenDialog import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.ui.store.AppTheme import com.duckduckgo.common.ui.view.TypeAnimationTextView @@ -116,6 +118,7 @@ class BbWelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome is ShowSkipOnboardingOption -> configureDaxCta(SKIP_ONBOARDING_OPTION) is ShowDefaultBrowserDialog -> showDefaultBrowserDialog(it.intent) is ShowAddressBarPositionDialog -> configureDaxCta(ADDRESS_BAR_POSITION) + is ShowInputScreenDialog -> onContinuePressed() is Finish -> onContinuePressed() is OnboardingSkipped -> onSkipPressed() is SetAddressBarPositionOptions -> setAddressBarPositionOptions(it.defaultOption) @@ -436,6 +439,10 @@ class BbWelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome scheduleTypingAnimation(binding.daxDialogCta.addressBarPosition.dialogTitle, titleText) { afterTypingAnimation() } backgroundSceneManager?.transitionToNextTile(expectedTile = TILE_04) } + + INPUT_SCREEN -> { + // Ignored + } } backgroundSceneManager?.setBackgroundClickListener(afterTypingAnimation) binding.daxDialogCta.cardContainer.setOnClickListener { afterTypingAnimation() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BuckWelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BuckWelcomePage.kt index 2163af97e812..5bc2932d6685 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BuckWelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/BuckWelcomePage.kt @@ -53,6 +53,7 @@ import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.ADDRESS_BAR import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.COMPARISON_CHART import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL_REINSTALL_USER +import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INPUT_SCREEN import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.SKIP_ONBOARDING_OPTION import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.Finish import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.OnboardingSkipped @@ -62,6 +63,7 @@ import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowCo import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowDefaultBrowserDialog import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowInitialDialog import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowInitialReinstallUserDialog +import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowInputScreenDialog import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowSkipOnboardingOption import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.ui.store.AppTheme @@ -116,6 +118,7 @@ class BuckWelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welco is ShowSkipOnboardingOption -> configureDaxCta(SKIP_ONBOARDING_OPTION) is ShowDefaultBrowserDialog -> showDefaultBrowserDialog(it.intent) is ShowAddressBarPositionDialog -> configureDaxCta(ADDRESS_BAR_POSITION) + is ShowInputScreenDialog -> onContinuePressed() is Finish -> onContinuePressed() is OnboardingSkipped -> onSkipPressed() is SetAddressBarPositionOptions -> setAddressBarPositionOptions(it.defaultOption) @@ -395,6 +398,10 @@ class BuckWelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welco }, ) } + + INPUT_SCREEN -> { + // Ignored + } } binding.sceneBg.setOnClickListener { afterTypingAnimation() } binding.daxDialogCta.cardContainer.setOnClickListener { afterTypingAnimation() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/PreOnboardingDialogType.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/PreOnboardingDialogType.kt index d99b638c1351..e2d57d170af6 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/PreOnboardingDialogType.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/PreOnboardingDialogType.kt @@ -22,4 +22,5 @@ enum class PreOnboardingDialogType { SKIP_ONBOARDING_OPTION, COMPARISON_CHART, ADDRESS_BAR_POSITION, + INPUT_SCREEN, } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt index 48c9ff98e853..74687ff088a0 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt @@ -41,6 +41,7 @@ import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.ADDRESS_BAR import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.COMPARISON_CHART import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL_REINSTALL_USER +import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INPUT_SCREEN import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.SKIP_ONBOARDING_OPTION import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.Finish import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.OnboardingSkipped @@ -50,6 +51,7 @@ import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowCo import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowDefaultBrowserDialog import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowInitialDialog import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowInitialReinstallUserDialog +import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowInputScreenDialog import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowSkipOnboardingOption import com.duckduckgo.app.onboardingdesignexperiment.OnboardingDesignExperimentManager import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -64,6 +66,7 @@ import com.duckduckgo.di.scopes.FragmentScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import javax.inject.Inject +import com.duckduckgo.mobile.android.R as CommonR @InjectWith(FragmentScope::class) class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_page) { @@ -110,6 +113,7 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p is ShowSkipOnboardingOption -> configureDaxCta(SKIP_ONBOARDING_OPTION) is ShowDefaultBrowserDialog -> showDefaultBrowserDialog(it.intent) is ShowAddressBarPositionDialog -> configureDaxCta(ADDRESS_BAR_POSITION) + is ShowInputScreenDialog -> configureDaxCta(INPUT_SCREEN) is Finish -> onContinuePressed() is OnboardingSkipped -> onSkipPressed() is SetAddressBarPositionOptions -> setAddressBarPositionOptions(it.defaultOption) @@ -243,7 +247,7 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p binding.daxDialogCta.dialogTextCta.text = "" TransitionManager.beginDelayedTransition(binding.daxDialogCta.cardView, AutoTransition()) binding.daxDialogCta.progressBarText.show() - binding.daxDialogCta.progressBarText.text = "1 / 2" + binding.daxDialogCta.progressBarText.text = "1 / 3" binding.daxDialogCta.progressBar.show() binding.daxDialogCta.progressBar.progress = 1 val ctaText = it.getString(R.string.preOnboardingDaxDialog2Title) @@ -296,7 +300,7 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p binding.daxDialogCta.comparisonChart.root.gone() TransitionManager.beginDelayedTransition(binding.daxDialogCta.cardView, AutoTransition()) binding.daxDialogCta.progressBarText.show() - binding.daxDialogCta.progressBarText.text = "2 / 2" + binding.daxDialogCta.progressBarText.text = "2 / 3" binding.daxDialogCta.progressBar.show() binding.daxDialogCta.progressBar.progress = 2 val ctaText = it.getString(R.string.preOnboardingAddressBarTitle).run { @@ -328,6 +332,52 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p scheduleTypingAnimation(ctaText) { afterAnimation() } } + + INPUT_SCREEN -> { + binding.daxDialogCta.descriptionCta.gone() + binding.daxDialogCta.secondaryCta.gone() + binding.daxDialogCta.dialogTextCta.text = "" + binding.daxDialogCta.comparisonChart.root.gone() + binding.daxDialogCta.addressBarPosition.root.gone() + TransitionManager.beginDelayedTransition(binding.daxDialogCta.cardView, AutoTransition()) + binding.daxDialogCta.progressBarText.show() + binding.daxDialogCta.progressBarText.text = "3 / 3" + binding.daxDialogCta.progressBar.show() + binding.daxDialogCta.progressBar.progress = 3 + val ctaText = it.getString(R.string.preOnboardingAiChatAccessTitle) + binding.daxDialogCta.hiddenTextCta.text = ctaText.html(it) + binding.daxDialogCta.primaryCta.alpha = MIN_ALPHA + binding.daxDialogCta.duckAiInputScreenToggleContainer.show() + binding.daxDialogCta.duckAiInputScreenToggleContainer.alpha = MIN_ALPHA + + val isLightMode = appTheme.isLightModeEnabled() + updateAiChatToggleState(binding, isLightMode, withAi = true) + viewModel.onInputScreenOptionSelected(withAi = true) + + binding.daxDialogCta.duckAiInputScreenWithoutAiContainer.setOnClickListener { + updateAiChatToggleState(binding, isLightMode, withAi = false) + viewModel.onInputScreenOptionSelected(withAi = false) + } + binding.daxDialogCta.duckAiInputScreenWithAiContainer.setOnClickListener { + updateAiChatToggleState(binding, isLightMode, withAi = true) + viewModel.onInputScreenOptionSelected(withAi = true) + } + + val descriptionText = it.getString(R.string.preOnboardingAiChatAccessDescription) + binding.daxDialogCta.duckAiInputScreenToggleDescription.text = descriptionText.html(it) + binding.daxDialogCta.duckAiInputScreenToggleDescription.show() + binding.daxDialogCta.duckAiInputScreenToggleDescription.alpha = MIN_ALPHA + + afterAnimation = { + binding.daxDialogCta.dialogTextCta.finishAnimation() + binding.daxDialogCta.primaryCta.text = it.getString(R.string.preOnboardingAiChatAccessButton) + binding.daxDialogCta.primaryCta.setOnClickListener { viewModel.onPrimaryCtaClicked(INPUT_SCREEN) } + binding.daxDialogCta.primaryCta.animate().alpha(MAX_ALPHA).duration = ANIMATION_DURATION + binding.daxDialogCta.duckAiInputScreenToggleContainer.animate().alpha(MAX_ALPHA).duration = ANIMATION_DURATION + binding.daxDialogCta.duckAiInputScreenToggleDescription.animate().alpha(MAX_ALPHA).duration = ANIMATION_DURATION + } + scheduleTypingAnimation(ctaText) { afterAnimation() } + } } binding.sceneBg.setOnClickListener { afterAnimation() } binding.daxDialogCta.cardContainer.setOnClickListener { afterAnimation() } @@ -397,6 +447,42 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p binding.sceneBg.setImageResource(backgroundRes) } + private fun updateAiChatToggleState( + binding: ContentOnboardingWelcomePageBinding, + isLightMode: Boolean, + withAi: Boolean, + ) { + val withoutAiImageRes = when { + !withAi && isLightMode -> com.duckduckgo.duckchat.impl.R.drawable.searchbox_withoutai_active + !withAi && !isLightMode -> com.duckduckgo.duckchat.impl.R.drawable.searchbox_withoutai_active_dark + withAi && isLightMode -> com.duckduckgo.duckchat.impl.R.drawable.searchbox_withoutai_inactive + else -> com.duckduckgo.duckchat.impl.R.drawable.searchbox_withoutai_inactive_dark + } + val withAiImageRes = when { + withAi && isLightMode -> com.duckduckgo.duckchat.impl.R.drawable.searchbox_withai_active + withAi && !isLightMode -> com.duckduckgo.duckchat.impl.R.drawable.searchbox_withai_active_dark + !withAi && isLightMode -> com.duckduckgo.duckchat.impl.R.drawable.searchbox_withai_inactive + else -> com.duckduckgo.duckchat.impl.R.drawable.searchbox_withai_inactive_dark + } + + binding.daxDialogCta.duckAiInputScreenToggleWithoutAiImage.setImageResource(withoutAiImageRes) + binding.daxDialogCta.duckAiInputScreenToggleWithAiImage.setImageResource(withAiImageRes) + + val withoutAiCheckRes = if (!withAi) { + CommonR.drawable.ic_check_accent_24 + } else { + CommonR.drawable.ic_shape_circle_24 + } + val withAiCheckRes = if (withAi) { + CommonR.drawable.ic_check_accent_24 + } else { + CommonR.drawable.ic_shape_circle_24 + } + + binding.daxDialogCta.duckAiInputScreenToggleWithoutAiCheck.setImageResource(withoutAiCheckRes) + binding.daxDialogCta.duckAiInputScreenToggleWithAiCheck.setImageResource(withAiCheckRes) + } + companion object { private const val MIN_ALPHA = 0f private const val MAX_ALPHA = 1f diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt index 27e14fd1d33a..89ba9c975a97 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt @@ -25,10 +25,12 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel import com.duckduckgo.app.browser.omnibar.OmnibarType import com.duckduckgo.app.global.DefaultRoleBrowserDialog import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.ADDRESS_BAR_POSITION import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.COMPARISON_CHART import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INITIAL_REINSTALL_USER +import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.INPUT_SCREEN import com.duckduckgo.app.onboarding.ui.page.PreOnboardingDialogType.SKIP_ONBOARDING_OPTION import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.Finish import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.OnboardingSkipped @@ -78,11 +80,13 @@ class WelcomePageViewModel @Inject constructor( private val dispatchers: DispatcherProvider, private val appBuildConfig: AppBuildConfig, private val onboardingDesignExperimentManager: OnboardingDesignExperimentManager, + private val onboardingStore: OnboardingStore, ) : ViewModel() { private val _commands = Channel(1, DROP_OLDEST) val commands: Flow = _commands.receiveAsFlow() private var defaultAddressBarPosition: Boolean = true + private var inputScreenSelected: Boolean = true sealed interface Command { data object ShowInitialReinstallUserDialog : Command @@ -99,6 +103,8 @@ class WelcomePageViewModel @Inject constructor( data object ShowAddressBarPositionDialog : Command + data object ShowInputScreenDialog : Command + data object Finish : Command data object OnboardingSkipped : Command @@ -163,6 +169,13 @@ class WelcomePageViewModel @Inject constructor( } else { onboardingDesignExperimentManager.fireAddressBarSetTopPixel() } + _commands.send(Command.ShowInputScreenDialog) + } + } + + INPUT_SCREEN -> { + viewModelScope.launch(dispatchers.io()) { + onboardingStore.storeInputScreenSelection(inputScreenSelected) _commands.send(Finish) } } @@ -196,6 +209,10 @@ class WelcomePageViewModel @Inject constructor( ADDRESS_BAR_POSITION -> { // no-op } + + INPUT_SCREEN -> { + // no-op + } } } @@ -255,6 +272,9 @@ class WelcomePageViewModel @Inject constructor( onboardingDesignExperimentManager.fireSetAddressBarDisplayedPixel() } } + INPUT_SCREEN -> { + // Pixel tracking can be added here if needed + } } } @@ -265,6 +285,10 @@ class WelcomePageViewModel @Inject constructor( } } + fun onInputScreenOptionSelected(withAi: Boolean) { + inputScreenSelected = withAi + } + fun loadDaxDialog() { viewModelScope.launch { if (isAppReinstall()) { diff --git a/app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml b/app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml index 20d96eeea166..3c5d32566af9 100644 --- a/app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml +++ b/app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml @@ -141,6 +141,109 @@ layout="@layout/pre_onboarding_address_bar_position" android:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + + + + Easy to see Bottom Easy to reach + Want easy access to private AI chat? + Next + Settings > AI Features.]]> Try a search! Your DuckDuckGo searches are always private. how to say "duck" in english diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt index e4dc75d84b38..21772b638511 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -67,4 +67,9 @@ interface DuckChat { * Displays the new address bar option choice screen. */ fun showNewAddressBarOptionChoiceScreen(context: Context, isDarkThemeEnabled: Boolean) + + /** + * Set user setting to determine whether dedicated Duck.ai input screen with a mode switch should be used. + */ + suspend fun setInputScreenUserSetting(enabled: Boolean) } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index b431668f2810..a1b61aa4b4e8 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -75,11 +75,6 @@ interface DuckChatInternal : DuckChat { */ suspend fun setEnableDuckChatUserSetting(enabled: Boolean) - /** - * Set user setting to determine whether dedicated Duck.ai input screen with a mode switch should be used. - */ - suspend fun setInputScreenUserSetting(enabled: Boolean) - /** * Set user setting to determine whether DuckChat should be shown in browser menu. */ From 20cdef2e1273b367c359b179e8bb6fc1fdb8e545 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Tue, 25 Nov 2025 11:34:30 +0000 Subject: [PATCH 02/11] Add Input Screen onboarding step # Conflicts: # app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt --- .../app/onboarding/ui/page/WelcomePage.kt | 18 ++- .../ui/page/WelcomePageViewModel.kt | 25 +++- .../AndroidBrowserConfigFeature.kt | 8 ++ app/src/main/res/values/donottranslate.xml | 5 + app/src/main/res/values/strings.xml | 3 - .../NewAddressBarOptionManagerTest.kt | 28 ++++ ...oardingInputScreenSelectionObserverTest.kt | 123 ++++++++++++++++++ .../ui/page/WelcomePageViewModelTest.kt | 71 ++++++++++ .../impl/messaging/fakes/FakeDuckChat.kt | 4 + 9 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt index 74687ff088a0..a7c62baeb0e9 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePage.kt @@ -247,8 +247,10 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p binding.daxDialogCta.dialogTextCta.text = "" TransitionManager.beginDelayedTransition(binding.daxDialogCta.cardView, AutoTransition()) binding.daxDialogCta.progressBarText.show() - binding.daxDialogCta.progressBarText.text = "1 / 3" + val maxPages = viewModel.getMaxPageCount() + binding.daxDialogCta.progressBarText.text = "1 / $maxPages" binding.daxDialogCta.progressBar.show() + binding.daxDialogCta.progressBar.max = maxPages binding.daxDialogCta.progressBar.progress = 1 val ctaText = it.getString(R.string.preOnboardingDaxDialog2Title) binding.daxDialogCta.hiddenTextCta.text = ctaText.html(it) @@ -300,8 +302,10 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p binding.daxDialogCta.comparisonChart.root.gone() TransitionManager.beginDelayedTransition(binding.daxDialogCta.cardView, AutoTransition()) binding.daxDialogCta.progressBarText.show() - binding.daxDialogCta.progressBarText.text = "2 / 3" + val maxPages = viewModel.getMaxPageCount() + binding.daxDialogCta.progressBarText.text = "2 / $maxPages" binding.daxDialogCta.progressBar.show() + binding.daxDialogCta.progressBar.max = maxPages binding.daxDialogCta.progressBar.progress = 2 val ctaText = it.getString(R.string.preOnboardingAddressBarTitle).run { if (onboardingDesignExperimentManager.isModifiedControlEnrolledAndEnabled()) { @@ -341,10 +345,12 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p binding.daxDialogCta.addressBarPosition.root.gone() TransitionManager.beginDelayedTransition(binding.daxDialogCta.cardView, AutoTransition()) binding.daxDialogCta.progressBarText.show() - binding.daxDialogCta.progressBarText.text = "3 / 3" + val maxPages = viewModel.getMaxPageCount() + binding.daxDialogCta.progressBarText.text = "3 / $maxPages" binding.daxDialogCta.progressBar.show() + binding.daxDialogCta.progressBar.max = maxPages binding.daxDialogCta.progressBar.progress = 3 - val ctaText = it.getString(R.string.preOnboardingAiChatAccessTitle) + val ctaText = it.getString(R.string.preOnboardingInputScreenTitle) binding.daxDialogCta.hiddenTextCta.text = ctaText.html(it) binding.daxDialogCta.primaryCta.alpha = MIN_ALPHA binding.daxDialogCta.duckAiInputScreenToggleContainer.show() @@ -363,14 +369,14 @@ class WelcomePage : OnboardingPageFragment(R.layout.content_onboarding_welcome_p viewModel.onInputScreenOptionSelected(withAi = true) } - val descriptionText = it.getString(R.string.preOnboardingAiChatAccessDescription) + val descriptionText = it.getString(R.string.preOnboardingInputScreenDescription) binding.daxDialogCta.duckAiInputScreenToggleDescription.text = descriptionText.html(it) binding.daxDialogCta.duckAiInputScreenToggleDescription.show() binding.daxDialogCta.duckAiInputScreenToggleDescription.alpha = MIN_ALPHA afterAnimation = { binding.daxDialogCta.dialogTextCta.finishAnimation() - binding.daxDialogCta.primaryCta.text = it.getString(R.string.preOnboardingAiChatAccessButton) + binding.daxDialogCta.primaryCta.text = it.getString(R.string.preOnboardingInputScreenButton) binding.daxDialogCta.primaryCta.setOnClickListener { viewModel.onPrimaryCtaClicked(INPUT_SCREEN) } binding.daxDialogCta.primaryCta.animate().alpha(MAX_ALPHA).duration = ANIMATION_DURATION binding.daxDialogCta.duckAiInputScreenToggleContainer.animate().alpha(MAX_ALPHA).duration = ANIMATION_DURATION diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt index 89ba9c975a97..dcd62bfa436a 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt @@ -54,6 +54,7 @@ import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_INTRO_SHOWN_UNIQUE import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_RESUME_ONBOARDING_PRESSED import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_SKIP_ONBOARDING_PRESSED import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_SKIP_ONBOARDING_SHOWN_UNIQUE +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter @@ -81,12 +82,24 @@ class WelcomePageViewModel @Inject constructor( private val appBuildConfig: AppBuildConfig, private val onboardingDesignExperimentManager: OnboardingDesignExperimentManager, private val onboardingStore: OnboardingStore, + private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, ) : ViewModel() { private val _commands = Channel(1, DROP_OLDEST) val commands: Flow = _commands.receiveAsFlow() private var defaultAddressBarPosition: Boolean = true private var inputScreenSelected: Boolean = true + private var maxPageCount: Int = 2 + + init { + viewModelScope.launch(dispatchers.io()) { + maxPageCount = if (androidBrowserConfigFeature.showInputScreenOnboarding().isEnabled()) { + 3 + } else { + 2 + } + } + } sealed interface Command { data object ShowInitialReinstallUserDialog : Command @@ -169,7 +182,11 @@ class WelcomePageViewModel @Inject constructor( } else { onboardingDesignExperimentManager.fireAddressBarSetTopPixel() } - _commands.send(Command.ShowInputScreenDialog) + if (androidBrowserConfigFeature.showInputScreenOnboarding().isEnabled()) { + _commands.send(Command.ShowInputScreenDialog) + } else { + _commands.send(Finish) + } } } @@ -273,7 +290,7 @@ class WelcomePageViewModel @Inject constructor( } } INPUT_SCREEN -> { - // Pixel tracking can be added here if needed + // no-op } } } @@ -289,6 +306,10 @@ class WelcomePageViewModel @Inject constructor( inputScreenSelected = withAi } + fun getMaxPageCount(): Int { + return maxPageCount + } + fun loadDaxDialog() { viewModelScope.launch { if (isAppReinstall()) { diff --git a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt index b1e233b9206d..b725ed0d317c 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/remoteconfig/AndroidBrowserConfigFeature.kt @@ -221,4 +221,12 @@ interface AndroidBrowserConfigFeature { @Toggle.DefaultValue(DefaultFeatureValue.FALSE) @Toggle.InternalAlwaysEnabled fun newCustomTab(): Toggle + + /** + * @return `true` when the remote config has the global "showInputScreenOnboarding" androidBrowserConfig + * sub-feature flag enabled + * If the remote feature is not present defaults to `internal` + */ + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun showInputScreenOnboarding(): Toggle } diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index eb962fb26802..bf1f7c270c27 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -65,4 +65,9 @@ Android Design System Demo + + + Want easy access to private AI chat? + Next + Settings > AI Features.]]> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8c539f3ab0d4..8ba5081165b2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -782,9 +782,6 @@ Easy to see Bottom Easy to reach - Want easy access to private AI chat? - Next - Settings > AI Features.]]> Try a search! Your DuckDuckGo searches are always private. how to say "duck" in english diff --git a/app/src/test/java/com/duckduckgo/app/browser/newaddressbaroption/NewAddressBarOptionManagerTest.kt b/app/src/test/java/com/duckduckgo/app/browser/newaddressbaroption/NewAddressBarOptionManagerTest.kt index c57863360ccb..5325a3ea1cad 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/newaddressbaroption/NewAddressBarOptionManagerTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/newaddressbaroption/NewAddressBarOptionManagerTest.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.browser.newaddressbaroption import com.duckduckgo.app.browser.omnibar.OmnibarType import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.store.UserStageStore import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.common.test.CoroutineTestRule @@ -62,6 +63,9 @@ class NewAddressBarOptionManagerTest { @Mock private var settingsDataStoreMock: SettingsDataStore = mock() + @Mock + private var onboardingStoreMock: OnboardingStore = mock() + private val showNewAddressBarOptionAnnouncementFlow = MutableStateFlow(false) private val showOmnibarShortcutInAllStatesFlow = MutableStateFlow(false) private val showInputScreenFlow = MutableStateFlow(false) @@ -85,6 +89,7 @@ class NewAddressBarOptionManagerTest { remoteMessagingRepositoryMock, newAddressBarOptionDataStoreMock, settingsDataStoreMock, + onboardingStoreMock, coroutineTestRule.testDispatcherProvider, ) } @@ -294,6 +299,28 @@ class NewAddressBarOptionManagerTest { verify(newAddressBarOptionDataStoreMock).setAsShown() } + @Test + fun `when input screen selection exists then showChoiceScreen does not show dialog`() = + runTest { + setupAllConditionsMet() + whenever(onboardingStoreMock.getInputScreenSelection()).thenReturn(true) + + testee.showChoiceScreen(mock(), isLaunchedFromExternal = false) + + verify(duckChatMock, never()).showNewAddressBarOptionChoiceScreen(any(), any()) + } + + @Test + fun `when input screen selection is false then showChoiceScreen does not show dialog`() = + runTest { + setupAllConditionsMet() + whenever(onboardingStoreMock.getInputScreenSelection()).thenReturn(false) + + testee.showChoiceScreen(mock(), isLaunchedFromExternal = false) + + verify(duckChatMock, never()).showNewAddressBarOptionChoiceScreen(any(), any()) + } + private suspend fun setupAllConditionsMet() { whenever(duckChatMock.isEnabled()).thenReturn(true) whenever(userStageStoreMock.getUserAppStage()).thenReturn(AppStage.ESTABLISHED) @@ -304,5 +331,6 @@ class NewAddressBarOptionManagerTest { whenever(remoteMessagingRepositoryMock.dismissedMessages()).thenReturn(emptyList()) whenever(settingsDataStoreMock.omnibarType).thenReturn(OmnibarType.SINGLE_TOP) whenever(newAddressBarOptionDataStoreMock.wasValidated()).thenReturn(true) + whenever(onboardingStoreMock.getInputScreenSelection()).thenReturn(null) } } diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt new file mode 100644 index 000000000000..21e7b21f4636 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt @@ -0,0 +1,123 @@ +/* + * 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.onboarding + +import com.duckduckgo.app.onboarding.store.AppStage +import com.duckduckgo.app.onboarding.store.OnboardingStore +import com.duckduckgo.app.onboarding.store.UserStageStore +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.duckchat.api.DuckChat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class OnboardingInputScreenSelectionObserverTest { + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val mockAppCoroutineScope: CoroutineScope = coroutineRule.testScope + private val mockUserStageStore: UserStageStore = mock() + private val mockOnboardingStore: OnboardingStore = mock() + private val mockDuckChat: DuckChat = mock() + private val dispatcherProvider: DispatcherProvider = coroutineRule.testDispatcherProvider + + private val userAppStageFlow = MutableStateFlow(AppStage.NEW) + + @Test + fun whenUserStageIsEstablishedAndInputScreenSelectionIsTrueThenSetInputScreenUserSettingToTrue() = + runTest { + whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) + + OnboardingInputScreenSelectionObserver( + mockAppCoroutineScope, + dispatcherProvider, + mockUserStageStore, + mockOnboardingStore, + mockDuckChat, + ) + + userAppStageFlow.value = AppStage.ESTABLISHED + + verify(mockDuckChat).setInputScreenUserSetting(true) + } + + @Test + fun whenUserStageIsEstablishedAndInputScreenSelectionIsFalseThenSetInputScreenUserSettingToFalse() = + runTest { + whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(false) + + OnboardingInputScreenSelectionObserver( + mockAppCoroutineScope, + dispatcherProvider, + mockUserStageStore, + mockOnboardingStore, + mockDuckChat, + ) + + userAppStageFlow.value = AppStage.ESTABLISHED + + verify(mockDuckChat).setInputScreenUserSetting(false) + } + + @Test + fun whenUserStageIsEstablishedAndInputScreenSelectionIsNullThenDoNotSetInputScreenUserSetting() = + runTest { + whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(null) + + OnboardingInputScreenSelectionObserver( + mockAppCoroutineScope, + dispatcherProvider, + mockUserStageStore, + mockOnboardingStore, + mockDuckChat, + ) + + userAppStageFlow.value = AppStage.ESTABLISHED + + verify(mockDuckChat, never()).setInputScreenUserSetting(any()) + } + + @Test + fun whenUserStageIsNotEstablishedThenDoNotSetInputScreenUserSetting() = + runTest { + whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) + + OnboardingInputScreenSelectionObserver( + mockAppCoroutineScope, + dispatcherProvider, + mockUserStageStore, + mockOnboardingStore, + mockDuckChat, + ) + + userAppStageFlow.value = AppStage.DAX_ONBOARDING + + verify(mockDuckChat, never()).setInputScreenUserSetting(any()) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt index a5c3b92b6f39..c6c45565136c 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt @@ -22,6 +22,7 @@ import app.cash.turbine.test import com.duckduckgo.app.browser.omnibar.OmnibarType import com.duckduckgo.app.global.DefaultRoleBrowserDialog import com.duckduckgo.app.global.install.AppInstallStore +import com.duckduckgo.app.onboarding.store.OnboardingStore import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.Finish import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.OnboardingSkipped import com.duckduckgo.app.onboarding.ui.page.WelcomePageViewModel.Command.ShowAddressBarPositionDialog @@ -42,12 +43,15 @@ import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_INTRO_SHOWN_UNIQUE import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_RESUME_ONBOARDING_PRESSED import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_SKIP_ONBOARDING_PRESSED import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_SKIP_ONBOARDING_SHOWN_UNIQUE +import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest import org.junit.Assert import org.junit.Rule @@ -68,6 +72,25 @@ class WelcomePageViewModelTest { private val mockSettingsDataStore: SettingsDataStore = mock() private val mockAppBuildConfig: AppBuildConfig = mock() private val mockOnboardingDesignExperimentManager: OnboardingDesignExperimentManager = mock() + private val mockOnboardingStore: OnboardingStore = mock() + private val mockAndroidBrowserConfigFeature: AndroidBrowserConfigFeature = FakeFeatureToggleFactory.create( + AndroidBrowserConfigFeature::class.java, + ) + + private fun createViewModel(): WelcomePageViewModel { + return WelcomePageViewModel( + mockDefaultRoleBrowserDialog, + mockContext, + mockPixel, + mockAppInstallStore, + mockSettingsDataStore, + coroutineRule.testDispatcherProvider, + mockAppBuildConfig, + mockOnboardingDesignExperimentManager, + mockOnboardingStore, + mockAndroidBrowserConfigFeature, + ) + } private val testee: WelcomePageViewModel by lazy { WelcomePageViewModel( @@ -79,6 +102,8 @@ class WelcomePageViewModelTest { coroutineRule.testDispatcherProvider, mockAppBuildConfig, mockOnboardingDesignExperimentManager, + mockOnboardingStore, + mockAndroidBrowserConfigFeature, ) } @@ -387,4 +412,50 @@ class WelcomePageViewModelTest { } verify(mockPixel).fire(PREONBOARDING_RESUME_ONBOARDING_PRESSED) } + + @Test + fun whenOnPrimaryCtaClickedWithInputScreenSelectedThenStoreSelectionAndFinish() = + runTest { + mockAndroidBrowserConfigFeature.showInputScreenOnboarding().setRawStoredState(Toggle.State(enable = true)) + testee.onInputScreenOptionSelected(true) + testee.onPrimaryCtaClicked(PreOnboardingDialogType.INPUT_SCREEN) + + testee.commands.test { + val command = awaitItem() + Assert.assertTrue(command is Finish) + } + verify(mockOnboardingStore).storeInputScreenSelection(true) + } + + @Test + fun whenOnPrimaryCtaClickedWithInputScreenNotSelectedThenStoreSelectionAndFinish() = + runTest { + mockAndroidBrowserConfigFeature.showInputScreenOnboarding().setRawStoredState(Toggle.State(enable = true)) + testee.onInputScreenOptionSelected(false) + testee.onPrimaryCtaClicked(PreOnboardingDialogType.INPUT_SCREEN) + + testee.commands.test { + val command = awaitItem() + Assert.assertTrue(command is Finish) + } + verify(mockOnboardingStore).storeInputScreenSelection(false) + } + + @Test + fun whenInputScreenOnboardingIsEnabledThenGetMaxPageCountReturns3() = + runTest { + mockAndroidBrowserConfigFeature.showInputScreenOnboarding().setRawStoredState(Toggle.State(enable = true)) + val viewModel = createViewModel() + + Assert.assertEquals(3, viewModel.getMaxPageCount()) + } + + @Test + fun whenInputScreenOnboardingIsDisabledThenGetMaxPageCountReturns2() = + runTest { + mockAndroidBrowserConfigFeature.showInputScreenOnboarding().setRawStoredState(Toggle.State(enable = false)) + val viewModel = createViewModel() + + Assert.assertEquals(2, viewModel.getMaxPageCount()) + } } diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt index ce7a189c204d..cdaa59b129d4 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt @@ -65,6 +65,10 @@ class FakeDuckChat( // No-op for testing } + override suspend fun setInputScreenUserSetting(enabled: Boolean) { + // No-op for testing + } + fun setEnabled(enabled: Boolean) { this.enabled = enabled } From 02ad73115fa364186379f8b7bdaa7da42aae94e3 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Wed, 26 Nov 2025 11:31:52 +0000 Subject: [PATCH 03/11] Maintain image aspect ratio --- app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml | 2 ++ .../src/main/res/layout/activity_duck_chat_settings.xml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml b/app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml index 3c5d32566af9..9fc5f08d2076 100644 --- a/app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml +++ b/app/src/main/res/layout/pre_onboarding_dax_dialog_cta.xml @@ -168,6 +168,7 @@ android:id="@+id/duckAiInputScreenToggleWithoutAiImage" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:adjustViewBounds="true" android:scaleType="fitXY" android:foreground="@drawable/selectable_large_rounded_ripple" android:src="@drawable/searchbox_withoutai_active" /> @@ -209,6 +210,7 @@ android:id="@+id/duckAiInputScreenToggleWithAiImage" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:adjustViewBounds="true" android:scaleType="fitXY" android:foreground="@drawable/selectable_large_rounded_ripple" android:src="@drawable/searchbox_withai_inactive" /> diff --git a/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml index 42b5eb2db039..c7800a3a1ac9 100644 --- a/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml +++ b/duckchat/duckchat-impl/src/main/res/layout/activity_duck_chat_settings.xml @@ -107,6 +107,7 @@ android:id="@+id/duckAiInputScreenToggleWithoutAiImage" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:adjustViewBounds="true" android:scaleType="fitXY" android:foreground="@drawable/selectable_large_rounded_ripple" android:src="@drawable/searchbox_withoutai_active" /> @@ -147,6 +148,7 @@ android:id="@+id/duckAiInputScreenToggleWithAiImage" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:adjustViewBounds="true" android:scaleType="fitXY" android:foreground="@drawable/selectable_large_rounded_ripple" android:src="@drawable/searchbox_withai_inactive" /> From 60df7039382b01f867cb088e52148b5de8f13d91 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 27 Nov 2025 21:02:20 +0000 Subject: [PATCH 04/11] Respect user setting set before onboarding is completed --- .../OnboardingInputScreenSelectionObserver.kt | 43 ++++++++++++++----- .../app/onboarding/store/OnboardingStore.kt | 1 + .../onboarding/store/OnboardingStoreImpl.kt | 4 ++ ...oardingInputScreenSelectionObserverTest.kt | 35 ++++++++++++++- .../com/duckduckgo/duckchat/api/DuckChat.kt | 6 +++ .../duckduckgo/duckchat/impl/RealDuckChat.kt | 5 --- 6 files changed, 77 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt index 9901ac3adafb..6822b8d9fd26 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt @@ -27,8 +27,11 @@ import com.duckduckgo.duckchat.api.DuckChat import com.squareup.anvil.annotations.ContributesMultibinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @ContributesMultibinding( @@ -37,22 +40,40 @@ import javax.inject.Inject ) class OnboardingInputScreenSelectionObserver @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, - dispatchers: DispatcherProvider, + private val dispatchers: DispatcherProvider, private val userStageStore: UserStageStore, private val onboardingStore: OnboardingStore, private val duckChat: DuckChat, ) : MainProcessLifecycleObserver { init { - appCoroutineScope.launch(dispatchers.io()) { - userStageStore.userAppStageFlow() - .distinctUntilChanged() - .filter { it == AppStage.ESTABLISHED } - .collect { - onboardingStore.getInputScreenSelection()?.let { selection -> - duckChat.setInputScreenUserSetting(selection) - } + observeInputScreenSetting() + setSelectionWhenEstablished() + } + + private fun observeInputScreenSetting() { + duckChat.observeInputScreenUserSettingEnabled() + .distinctUntilChanged() + .drop(1) + .onEach { + if (userStageStore.getUserAppStage() != AppStage.ESTABLISHED && onboardingStore.getInputScreenSelection() != null) { + onboardingStore.clearInputScreenSelection() + } + } + .flowOn(dispatchers.io()) + .launchIn(appCoroutineScope) + } + + private fun setSelectionWhenEstablished() { + userStageStore.userAppStageFlow() + .distinctUntilChanged() + .filter { it == AppStage.ESTABLISHED } + .onEach { + onboardingStore.getInputScreenSelection()?.let { selection -> + duckChat.setInputScreenUserSetting(selection) } - } + } + .flowOn(dispatchers.io()) + .launchIn(appCoroutineScope) } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt index 3bf23addb746..8304fb5ccda7 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt @@ -25,4 +25,5 @@ interface OnboardingStore { fun getSitesOptions(): List fun storeInputScreenSelection(selected: Boolean) fun getInputScreenSelection(): Boolean? + fun clearInputScreenSelection() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt index e610313ecc82..e4738665b73d 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt @@ -175,6 +175,10 @@ class OnboardingStoreImpl @Inject constructor( } } + override fun clearInputScreenSelection() { + preferences.edit { remove(KEY_INPUT_SCREEN_SELECTION) } + } + companion object { const val FILENAME = "com.duckduckgo.app.onboarding.settings" const val ONBOARDING_JOURNEY = "onboardingJourney" diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt index 21e7b21f4636..92c7797d527f 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt @@ -42,14 +42,16 @@ class OnboardingInputScreenSelectionObserverTest { private val mockOnboardingStore: OnboardingStore = mock() private val mockDuckChat: DuckChat = mock() private val dispatcherProvider: DispatcherProvider = coroutineRule.testDispatcherProvider - private val userAppStageFlow = MutableStateFlow(AppStage.NEW) + private val inputScreenSettingFlow = MutableStateFlow(false) @Test fun whenUserStageIsEstablishedAndInputScreenSelectionIsTrueThenSetInputScreenUserSettingToTrue() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -68,7 +70,9 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsEstablishedAndInputScreenSelectionIsFalseThenSetInputScreenUserSettingToFalse() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(false) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -87,7 +91,9 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsEstablishedAndInputScreenSelectionIsNullThenDoNotSetInputScreenUserSetting() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(null) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -106,7 +112,9 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsNotEstablishedThenDoNotSetInputScreenUserSetting() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -118,6 +126,31 @@ class OnboardingInputScreenSelectionObserverTest { userAppStageFlow.value = AppStage.DAX_ONBOARDING + verify(mockDuckChat, never()).setInputScreenUserSetting(any()) + } + + @Test + fun whenUserChangesInputScreenSettingBeforeEstablishedThenClearOnboardingSelection() = + runTest { + whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) + whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true, null) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) + + OnboardingInputScreenSelectionObserver( + mockAppCoroutineScope, + dispatcherProvider, + mockUserStageStore, + mockOnboardingStore, + mockDuckChat, + ) + + inputScreenSettingFlow.value = true + + verify(mockOnboardingStore).clearInputScreenSelection() + + userAppStageFlow.value = AppStage.ESTABLISHED + verify(mockDuckChat, never()).setInputScreenUserSetting(any()) } } diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt index 21772b638511..005a51df467b 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -18,6 +18,7 @@ package com.duckduckgo.duckchat.api import android.content.Context import android.net.Uri +import kotlinx.coroutines.flow.Flow /** * DuckChat interface provides a set of methods for interacting and controlling DuckChat. @@ -72,4 +73,9 @@ interface DuckChat { * Set user setting to determine whether dedicated Duck.ai input screen with a mode switch should be used. */ suspend fun setInputScreenUserSetting(enabled: Boolean) + + /** + * Observes whether Duck.ai input screen with a mode switch is enabled or disabled. + */ + fun observeInputScreenUserSettingEnabled(): Flow } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index a1b61aa4b4e8..f4869928d7ea 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -100,11 +100,6 @@ interface DuckChatInternal : DuckChat { */ fun observeEnableDuckChatUserSetting(): Flow - /** - * Observes whether Duck.ai input screen with a mode switch is enabled or disabled. - */ - fun observeInputScreenUserSettingEnabled(): Flow - /** * Observes whether DuckChat should be shown in browser menu based on user settings only. */ From 9f131bab6b6d2a6608bc1f7e537d92278f289ca1 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 27 Nov 2025 21:22:42 +0000 Subject: [PATCH 05/11] Revert "Respect user setting set before onboarding is completed" This reverts commit 60df7039382b01f867cb088e52148b5de8f13d91. --- .../OnboardingInputScreenSelectionObserver.kt | 43 +++++-------------- .../app/onboarding/store/OnboardingStore.kt | 1 - .../onboarding/store/OnboardingStoreImpl.kt | 4 -- ...oardingInputScreenSelectionObserverTest.kt | 35 +-------------- .../com/duckduckgo/duckchat/api/DuckChat.kt | 6 --- .../duckduckgo/duckchat/impl/RealDuckChat.kt | 5 +++ 6 files changed, 17 insertions(+), 77 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt index 6822b8d9fd26..9901ac3adafb 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt @@ -27,11 +27,8 @@ import com.duckduckgo.duckchat.api.DuckChat import com.squareup.anvil.annotations.ContributesMultibinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject @ContributesMultibinding( @@ -40,40 +37,22 @@ import javax.inject.Inject ) class OnboardingInputScreenSelectionObserver @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, - private val dispatchers: DispatcherProvider, + dispatchers: DispatcherProvider, private val userStageStore: UserStageStore, private val onboardingStore: OnboardingStore, private val duckChat: DuckChat, ) : MainProcessLifecycleObserver { init { - observeInputScreenSetting() - setSelectionWhenEstablished() - } - - private fun observeInputScreenSetting() { - duckChat.observeInputScreenUserSettingEnabled() - .distinctUntilChanged() - .drop(1) - .onEach { - if (userStageStore.getUserAppStage() != AppStage.ESTABLISHED && onboardingStore.getInputScreenSelection() != null) { - onboardingStore.clearInputScreenSelection() - } - } - .flowOn(dispatchers.io()) - .launchIn(appCoroutineScope) - } - - private fun setSelectionWhenEstablished() { - userStageStore.userAppStageFlow() - .distinctUntilChanged() - .filter { it == AppStage.ESTABLISHED } - .onEach { - onboardingStore.getInputScreenSelection()?.let { selection -> - duckChat.setInputScreenUserSetting(selection) + appCoroutineScope.launch(dispatchers.io()) { + userStageStore.userAppStageFlow() + .distinctUntilChanged() + .filter { it == AppStage.ESTABLISHED } + .collect { + onboardingStore.getInputScreenSelection()?.let { selection -> + duckChat.setInputScreenUserSetting(selection) + } } - } - .flowOn(dispatchers.io()) - .launchIn(appCoroutineScope) + } } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt index 8304fb5ccda7..3bf23addb746 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt @@ -25,5 +25,4 @@ interface OnboardingStore { fun getSitesOptions(): List fun storeInputScreenSelection(selected: Boolean) fun getInputScreenSelection(): Boolean? - fun clearInputScreenSelection() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt index e4738665b73d..e610313ecc82 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt @@ -175,10 +175,6 @@ class OnboardingStoreImpl @Inject constructor( } } - override fun clearInputScreenSelection() { - preferences.edit { remove(KEY_INPUT_SCREEN_SELECTION) } - } - companion object { const val FILENAME = "com.duckduckgo.app.onboarding.settings" const val ONBOARDING_JOURNEY = "onboardingJourney" diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt index 92c7797d527f..21e7b21f4636 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt @@ -42,16 +42,14 @@ class OnboardingInputScreenSelectionObserverTest { private val mockOnboardingStore: OnboardingStore = mock() private val mockDuckChat: DuckChat = mock() private val dispatcherProvider: DispatcherProvider = coroutineRule.testDispatcherProvider + private val userAppStageFlow = MutableStateFlow(AppStage.NEW) - private val inputScreenSettingFlow = MutableStateFlow(false) @Test fun whenUserStageIsEstablishedAndInputScreenSelectionIsTrueThenSetInputScreenUserSettingToTrue() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) - whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) - whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -70,9 +68,7 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsEstablishedAndInputScreenSelectionIsFalseThenSetInputScreenUserSettingToFalse() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) - whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(false) - whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -91,9 +87,7 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsEstablishedAndInputScreenSelectionIsNullThenDoNotSetInputScreenUserSetting() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) - whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(null) - whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -112,9 +106,7 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsNotEstablishedThenDoNotSetInputScreenUserSetting() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) - whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) - whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -126,31 +118,6 @@ class OnboardingInputScreenSelectionObserverTest { userAppStageFlow.value = AppStage.DAX_ONBOARDING - verify(mockDuckChat, never()).setInputScreenUserSetting(any()) - } - - @Test - fun whenUserChangesInputScreenSettingBeforeEstablishedThenClearOnboardingSelection() = - runTest { - whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) - whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) - whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true, null) - whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) - - OnboardingInputScreenSelectionObserver( - mockAppCoroutineScope, - dispatcherProvider, - mockUserStageStore, - mockOnboardingStore, - mockDuckChat, - ) - - inputScreenSettingFlow.value = true - - verify(mockOnboardingStore).clearInputScreenSelection() - - userAppStageFlow.value = AppStage.ESTABLISHED - verify(mockDuckChat, never()).setInputScreenUserSetting(any()) } } diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt index 005a51df467b..21772b638511 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -18,7 +18,6 @@ package com.duckduckgo.duckchat.api import android.content.Context import android.net.Uri -import kotlinx.coroutines.flow.Flow /** * DuckChat interface provides a set of methods for interacting and controlling DuckChat. @@ -73,9 +72,4 @@ interface DuckChat { * Set user setting to determine whether dedicated Duck.ai input screen with a mode switch should be used. */ suspend fun setInputScreenUserSetting(enabled: Boolean) - - /** - * Observes whether Duck.ai input screen with a mode switch is enabled or disabled. - */ - fun observeInputScreenUserSettingEnabled(): Flow } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index f4869928d7ea..a1b61aa4b4e8 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -100,6 +100,11 @@ interface DuckChatInternal : DuckChat { */ fun observeEnableDuckChatUserSetting(): Flow + /** + * Observes whether Duck.ai input screen with a mode switch is enabled or disabled. + */ + fun observeInputScreenUserSettingEnabled(): Flow + /** * Observes whether DuckChat should be shown in browser menu based on user settings only. */ From 8dd29163d4947dad53a75349e7a52bb62ffee964 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 27 Nov 2025 21:41:43 +0000 Subject: [PATCH 06/11] Reapply "Respect user setting set before onboarding is completed" This reverts commit 9f131bab6b6d2a6608bc1f7e537d92278f289ca1. --- .../OnboardingInputScreenSelectionObserver.kt | 43 ++++++++++++++----- .../app/onboarding/store/OnboardingStore.kt | 1 + .../onboarding/store/OnboardingStoreImpl.kt | 4 ++ ...oardingInputScreenSelectionObserverTest.kt | 35 ++++++++++++++- .../com/duckduckgo/duckchat/api/DuckChat.kt | 6 +++ .../duckduckgo/duckchat/impl/RealDuckChat.kt | 5 --- 6 files changed, 77 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt index 9901ac3adafb..6822b8d9fd26 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt @@ -27,8 +27,11 @@ import com.duckduckgo.duckchat.api.DuckChat import com.squareup.anvil.annotations.ContributesMultibinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import javax.inject.Inject @ContributesMultibinding( @@ -37,22 +40,40 @@ import javax.inject.Inject ) class OnboardingInputScreenSelectionObserver @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, - dispatchers: DispatcherProvider, + private val dispatchers: DispatcherProvider, private val userStageStore: UserStageStore, private val onboardingStore: OnboardingStore, private val duckChat: DuckChat, ) : MainProcessLifecycleObserver { init { - appCoroutineScope.launch(dispatchers.io()) { - userStageStore.userAppStageFlow() - .distinctUntilChanged() - .filter { it == AppStage.ESTABLISHED } - .collect { - onboardingStore.getInputScreenSelection()?.let { selection -> - duckChat.setInputScreenUserSetting(selection) - } + observeInputScreenSetting() + setSelectionWhenEstablished() + } + + private fun observeInputScreenSetting() { + duckChat.observeInputScreenUserSettingEnabled() + .distinctUntilChanged() + .drop(1) + .onEach { + if (userStageStore.getUserAppStage() != AppStage.ESTABLISHED && onboardingStore.getInputScreenSelection() != null) { + onboardingStore.clearInputScreenSelection() + } + } + .flowOn(dispatchers.io()) + .launchIn(appCoroutineScope) + } + + private fun setSelectionWhenEstablished() { + userStageStore.userAppStageFlow() + .distinctUntilChanged() + .filter { it == AppStage.ESTABLISHED } + .onEach { + onboardingStore.getInputScreenSelection()?.let { selection -> + duckChat.setInputScreenUserSetting(selection) } - } + } + .flowOn(dispatchers.io()) + .launchIn(appCoroutineScope) } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt index 3bf23addb746..8304fb5ccda7 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt @@ -25,4 +25,5 @@ interface OnboardingStore { fun getSitesOptions(): List fun storeInputScreenSelection(selected: Boolean) fun getInputScreenSelection(): Boolean? + fun clearInputScreenSelection() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt index e610313ecc82..e4738665b73d 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt @@ -175,6 +175,10 @@ class OnboardingStoreImpl @Inject constructor( } } + override fun clearInputScreenSelection() { + preferences.edit { remove(KEY_INPUT_SCREEN_SELECTION) } + } + companion object { const val FILENAME = "com.duckduckgo.app.onboarding.settings" const val ONBOARDING_JOURNEY = "onboardingJourney" diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt index 21e7b21f4636..92c7797d527f 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt @@ -42,14 +42,16 @@ class OnboardingInputScreenSelectionObserverTest { private val mockOnboardingStore: OnboardingStore = mock() private val mockDuckChat: DuckChat = mock() private val dispatcherProvider: DispatcherProvider = coroutineRule.testDispatcherProvider - private val userAppStageFlow = MutableStateFlow(AppStage.NEW) + private val inputScreenSettingFlow = MutableStateFlow(false) @Test fun whenUserStageIsEstablishedAndInputScreenSelectionIsTrueThenSetInputScreenUserSettingToTrue() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -68,7 +70,9 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsEstablishedAndInputScreenSelectionIsFalseThenSetInputScreenUserSettingToFalse() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(false) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -87,7 +91,9 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsEstablishedAndInputScreenSelectionIsNullThenDoNotSetInputScreenUserSetting() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(null) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -106,7 +112,9 @@ class OnboardingInputScreenSelectionObserverTest { fun whenUserStageIsNotEstablishedThenDoNotSetInputScreenUserSetting() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.DAX_ONBOARDING) whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( mockAppCoroutineScope, @@ -118,6 +126,31 @@ class OnboardingInputScreenSelectionObserverTest { userAppStageFlow.value = AppStage.DAX_ONBOARDING + verify(mockDuckChat, never()).setInputScreenUserSetting(any()) + } + + @Test + fun whenUserChangesInputScreenSettingBeforeEstablishedThenClearOnboardingSelection() = + runTest { + whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) + whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) + whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true, null) + whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) + + OnboardingInputScreenSelectionObserver( + mockAppCoroutineScope, + dispatcherProvider, + mockUserStageStore, + mockOnboardingStore, + mockDuckChat, + ) + + inputScreenSettingFlow.value = true + + verify(mockOnboardingStore).clearInputScreenSelection() + + userAppStageFlow.value = AppStage.ESTABLISHED + verify(mockDuckChat, never()).setInputScreenUserSetting(any()) } } diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt index 21772b638511..005a51df467b 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/DuckChat.kt @@ -18,6 +18,7 @@ package com.duckduckgo.duckchat.api import android.content.Context import android.net.Uri +import kotlinx.coroutines.flow.Flow /** * DuckChat interface provides a set of methods for interacting and controlling DuckChat. @@ -72,4 +73,9 @@ interface DuckChat { * Set user setting to determine whether dedicated Duck.ai input screen with a mode switch should be used. */ suspend fun setInputScreenUserSetting(enabled: Boolean) + + /** + * Observes whether Duck.ai input screen with a mode switch is enabled or disabled. + */ + fun observeInputScreenUserSettingEnabled(): Flow } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt index a1b61aa4b4e8..f4869928d7ea 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/RealDuckChat.kt @@ -100,11 +100,6 @@ interface DuckChatInternal : DuckChat { */ fun observeEnableDuckChatUserSetting(): Flow - /** - * Observes whether Duck.ai input screen with a mode switch is enabled or disabled. - */ - fun observeInputScreenUserSettingEnabled(): Flow - /** * Observes whether DuckChat should be shown in browser menu based on user settings only. */ From 146baa7954136a4a9ef8909cbb5f0bc8c29ee459 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 27 Nov 2025 22:00:25 +0000 Subject: [PATCH 07/11] Add user override methods --- .../onboarding/OnboardingInputScreenSelectionObserver.kt | 5 +++-- .../duckduckgo/app/onboarding/store/OnboardingStore.kt | 3 ++- .../app/onboarding/store/OnboardingStoreImpl.kt | 9 +++++++-- .../OnboardingInputScreenSelectionObserverTest.kt | 7 ++++--- 4 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt index 6822b8d9fd26..8672ccebdcce 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserver.kt @@ -57,7 +57,7 @@ class OnboardingInputScreenSelectionObserver @Inject constructor( .drop(1) .onEach { if (userStageStore.getUserAppStage() != AppStage.ESTABLISHED && onboardingStore.getInputScreenSelection() != null) { - onboardingStore.clearInputScreenSelection() + onboardingStore.setInputScreenSelectionOverriddenByUser() } } .flowOn(dispatchers.io()) @@ -69,7 +69,8 @@ class OnboardingInputScreenSelectionObserver @Inject constructor( .distinctUntilChanged() .filter { it == AppStage.ESTABLISHED } .onEach { - onboardingStore.getInputScreenSelection()?.let { selection -> + val selection = onboardingStore.getInputScreenSelection() + if (!onboardingStore.isInputScreenSelectionOverriddenByUser() && selection != null) { duckChat.setInputScreenUserSetting(selection) } } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt index 8304fb5ccda7..baf4723a3a6f 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt @@ -25,5 +25,6 @@ interface OnboardingStore { fun getSitesOptions(): List fun storeInputScreenSelection(selected: Boolean) fun getInputScreenSelection(): Boolean? - fun clearInputScreenSelection() + fun isInputScreenSelectionOverriddenByUser(): Boolean + fun setInputScreenSelectionOverriddenByUser() } diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt index e4738665b73d..e081d8c18e02 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt @@ -175,13 +175,18 @@ class OnboardingStoreImpl @Inject constructor( } } - override fun clearInputScreenSelection() { - preferences.edit { remove(KEY_INPUT_SCREEN_SELECTION) } + override fun isInputScreenSelectionOverriddenByUser(): Boolean { + return preferences.getBoolean(KEY_INPUT_SCREEN_SELECTION_OVERRIDDEN_BY_USER, false) + } + + override fun setInputScreenSelectionOverriddenByUser() { + preferences.edit { putBoolean(KEY_INPUT_SCREEN_SELECTION_OVERRIDDEN_BY_USER, true) } } companion object { const val FILENAME = "com.duckduckgo.app.onboarding.settings" const val ONBOARDING_JOURNEY = "onboardingJourney" private const val KEY_INPUT_SCREEN_SELECTION = "inputScreenSelection" + private const val KEY_INPUT_SCREEN_SELECTION_OVERRIDDEN_BY_USER = "inputScreenSelectionOverriddenByUser" } } diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt index 92c7797d527f..1ccd49e18ecd 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/OnboardingInputScreenSelectionObserverTest.kt @@ -130,11 +130,12 @@ class OnboardingInputScreenSelectionObserverTest { } @Test - fun whenUserChangesInputScreenSettingBeforeEstablishedThenClearOnboardingSelection() = + fun whenUserChangesInputScreenSettingBeforeEstablishedThenMarkAsOverriddenByUser() = runTest { whenever(mockUserStageStore.userAppStageFlow()).thenReturn(userAppStageFlow) whenever(mockUserStageStore.getUserAppStage()).thenReturn(AppStage.NEW) - whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true, null) + whenever(mockOnboardingStore.getInputScreenSelection()).thenReturn(true) + whenever(mockOnboardingStore.isInputScreenSelectionOverriddenByUser()).thenReturn(true) whenever(mockDuckChat.observeInputScreenUserSettingEnabled()).thenReturn(inputScreenSettingFlow) OnboardingInputScreenSelectionObserver( @@ -147,7 +148,7 @@ class OnboardingInputScreenSelectionObserverTest { inputScreenSettingFlow.value = true - verify(mockOnboardingStore).clearInputScreenSelection() + verify(mockOnboardingStore).setInputScreenSelectionOverriddenByUser() userAppStageFlow.value = AppStage.ESTABLISHED From 8fb11816418d6e9bc37d9893c86c9a701207cbd1 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 27 Nov 2025 23:18:24 +0000 Subject: [PATCH 08/11] Fix FakeDuckChat --- .../duckchat/impl/messaging/fakes/FakeDuckChat.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt index cdaa59b129d4..2023283f3ddf 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/messaging/fakes/FakeDuckChat.kt @@ -19,6 +19,8 @@ package com.duckduckgo.duckchat.impl.messaging.fakes import android.content.Context import android.net.Uri import com.duckduckgo.duckchat.api.DuckChat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow /** * Fake implementation of [DuckChat] for testing purposes. @@ -31,6 +33,7 @@ class FakeDuckChat( private val openDuckChatWithAutoPromptCalls = mutableListOf() private val openDuckChatWithPrefillCalls = mutableListOf() private var wasOpenedBeforeValue: Boolean = false + private val inputScreenUserSettingEnabled = MutableStateFlow(false) override fun isEnabled(): Boolean = enabled @@ -66,7 +69,11 @@ class FakeDuckChat( } override suspend fun setInputScreenUserSetting(enabled: Boolean) { - // No-op for testing + inputScreenUserSettingEnabled.value = enabled + } + + override fun observeInputScreenUserSettingEnabled(): Flow { + return inputScreenUserSettingEnabled } fun setEnabled(enabled: Boolean) { From 0f57bedbac6e3f75354ed27d681a15e5f7811778 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 27 Nov 2025 14:21:18 +0000 Subject: [PATCH 09/11] Add Input Screen onboarding wide event --- .../ui/page/WelcomePageViewModel.kt | 5 + .../duckchat/impl/feature/DuckChatFeature.kt | 7 ++ .../inputscreen/ui/InputScreenActivity.kt | 14 +++ .../InputScreenOnboardingWideEvent.kt | 102 ++++++++++++++++++ 4 files changed, 128 insertions(+) create mode 100644 duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/wideevents/InputScreenOnboardingWideEvent.kt diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt index dcd62bfa436a..bb93a9222743 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModel.kt @@ -62,6 +62,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.FragmentScope +import com.duckduckgo.duckchat.impl.inputscreen.wideevents.InputScreenOnboardingWideEvent import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -83,6 +84,7 @@ class WelcomePageViewModel @Inject constructor( private val onboardingDesignExperimentManager: OnboardingDesignExperimentManager, private val onboardingStore: OnboardingStore, private val androidBrowserConfigFeature: AndroidBrowserConfigFeature, + private val inputScreenOnboardingWideEvent: InputScreenOnboardingWideEvent, ) : ViewModel() { private val _commands = Channel(1, DROP_OLDEST) val commands: Flow = _commands.receiveAsFlow() @@ -193,6 +195,9 @@ class WelcomePageViewModel @Inject constructor( INPUT_SCREEN -> { viewModelScope.launch(dispatchers.io()) { onboardingStore.storeInputScreenSelection(inputScreenSelected) + if (inputScreenSelected) { + inputScreenOnboardingWideEvent.onInputScreenEnabledDuringOnboarding() + } _commands.send(Finish) } } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt index 578df68130d9..2d0ec35f2cf1 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/feature/DuckChatFeature.kt @@ -129,4 +129,11 @@ interface DuckChatFeature { */ @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) fun showHideAiGeneratedImages(): Toggle + + /** + * @return `true` when the Input Screen onboarding wide event should be sent + * If the remote feature is not present defaults to `internal` + */ + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun sendInputScreenOnboardingWideEvent(): Toggle } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenActivity.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenActivity.kt index d17ef9a80991..007ca7b91d96 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenActivity.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenActivity.kt @@ -21,6 +21,7 @@ import android.os.Build.VERSION import android.os.Bundle import com.duckduckgo.anvil.annotations.ContributeToActivityStarter import com.duckduckgo.anvil.annotations.InjectWith +import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.common.ui.DuckDuckGoActivity import com.duckduckgo.di.scopes.ActivityScope @@ -28,7 +29,10 @@ import com.duckduckgo.duckchat.api.inputscreen.BrowserAndInputScreenTransitionPr import com.duckduckgo.duckchat.api.inputscreen.InputScreenActivityParams import com.duckduckgo.duckchat.impl.R import com.duckduckgo.duckchat.impl.inputscreen.ui.metrics.discovery.InputScreenDiscoveryFunnel +import com.duckduckgo.duckchat.impl.inputscreen.wideevents.InputScreenOnboardingWideEvent import com.duckduckgo.duckchat.impl.pixel.DuckChatPixelName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import javax.inject.Inject @InjectWith(ActivityScope::class) @@ -46,6 +50,13 @@ class InputScreenActivity : DuckDuckGoActivity() { @Inject lateinit var inputScreenConfigResolver: InputScreenConfigResolver + @Inject + lateinit var inputScreenOnboardingWideEvent: InputScreenOnboardingWideEvent + + @Inject + @AppCoroutineScope + lateinit var appCoroutineScope: CoroutineScope + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_input_screen) @@ -60,6 +71,9 @@ class InputScreenActivity : DuckDuckGoActivity() { }, ) pixel.fire(pixel = DuckChatPixelName.DUCK_CHAT_EXPERIMENTAL_OMNIBAR_TEXT_AREA_FOCUSED, parameters = params) + appCoroutineScope.launch { + inputScreenOnboardingWideEvent.onInputScreenShown() + } } override fun finish() { diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/wideevents/InputScreenOnboardingWideEvent.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/wideevents/InputScreenOnboardingWideEvent.kt new file mode 100644 index 000000000000..113937fc08e7 --- /dev/null +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/wideevents/InputScreenOnboardingWideEvent.kt @@ -0,0 +1,102 @@ +/* + * 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.duckchat.impl.inputscreen.wideevents + +import com.duckduckgo.app.statistics.wideevents.CleanupPolicy.OnProcessStart +import com.duckduckgo.app.statistics.wideevents.FlowStatus +import com.duckduckgo.app.statistics.wideevents.WideEventClient +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.duckchat.impl.feature.DuckChatFeature +import com.squareup.anvil.annotations.ContributesBinding +import dagger.Lazy +import dagger.SingleInstanceIn +import kotlinx.coroutines.withContext +import javax.inject.Inject + +interface InputScreenOnboardingWideEvent { + /** + * Called when the user enables the Input Screen during onboarding + */ + suspend fun onInputScreenEnabledDuringOnboarding() + + /** + * Called when the Input Screen is shown to the user + */ + suspend fun onInputScreenShown() +} + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class InputScreenOnboardingWideEventImpl @Inject constructor( + private val wideEventClient: WideEventClient, + private val duckChatFeature: Lazy, + private val dispatchers: DispatcherProvider, +) : InputScreenOnboardingWideEvent { + + private var cachedFlowId: Long? = null + + override suspend fun onInputScreenEnabledDuringOnboarding() { + if (!isFeatureEnabled()) return + + getCurrentWideEventId()?.let { wideEventId -> + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Unknown, + ) + cachedFlowId = null + } + + cachedFlowId = wideEventClient + .flowStart( + name = INPUT_SCREEN_ONBOARDING_FEATURE_NAME, + flowEntryPoint = "onboarding", + cleanupPolicy = OnProcessStart(ignoreIfIntervalTimeoutPresent = true), + ) + .getOrNull() + } + + override suspend fun onInputScreenShown() { + if (!isFeatureEnabled()) return + val wideEventId = getCurrentWideEventId() ?: return + + wideEventClient.flowFinish( + wideEventId = wideEventId, + status = FlowStatus.Success, + ) + cachedFlowId = null + } + + private suspend fun isFeatureEnabled(): Boolean = withContext(dispatchers.io()) { + duckChatFeature.get().sendInputScreenOnboardingWideEvent().isEnabled() + } + + private suspend fun getCurrentWideEventId(): Long? { + if (cachedFlowId == null) { + cachedFlowId = wideEventClient + .getFlowIds(INPUT_SCREEN_ONBOARDING_FEATURE_NAME) + .getOrNull() + ?.lastOrNull() + } + + return cachedFlowId + } + + private companion object { + const val INPUT_SCREEN_ONBOARDING_FEATURE_NAME = "input-screen-onboarding" + } +} From d73929e2a82cc449e3669bb1ced6cfa45baba6c8 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Thu, 27 Nov 2025 23:20:53 +0000 Subject: [PATCH 10/11] Fix WelcomePageViewModelTest --- .../app/onboarding/ui/page/WelcomePageViewModelTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt index c6c45565136c..a6376c5ae53d 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt @@ -46,6 +46,7 @@ import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_SKIP_ONBOARDING_SHOW import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.duckchat.impl.inputscreen.wideevents.InputScreenOnboardingWideEvent import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.appbuildconfig.api.AppBuildConfig @@ -76,6 +77,7 @@ class WelcomePageViewModelTest { private val mockAndroidBrowserConfigFeature: AndroidBrowserConfigFeature = FakeFeatureToggleFactory.create( AndroidBrowserConfigFeature::class.java, ) + private val mockInputScreenOnboardingWideEvent: InputScreenOnboardingWideEvent = mock() private fun createViewModel(): WelcomePageViewModel { return WelcomePageViewModel( @@ -89,6 +91,7 @@ class WelcomePageViewModelTest { mockOnboardingDesignExperimentManager, mockOnboardingStore, mockAndroidBrowserConfigFeature, + mockInputScreenOnboardingWideEvent, ) } @@ -104,6 +107,7 @@ class WelcomePageViewModelTest { mockOnboardingDesignExperimentManager, mockOnboardingStore, mockAndroidBrowserConfigFeature, + mockInputScreenOnboardingWideEvent, ) } From a10b3ff7439d2ab9053bae36ca031ee9caf10a03 Mon Sep 17 00:00:00 2001 From: joshliebe Date: Fri, 28 Nov 2025 10:26:10 +0000 Subject: [PATCH 11/11] Fix formatiing --- .../app/onboarding/ui/page/WelcomePageViewModelTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt index a6376c5ae53d..179b8818f88d 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/page/WelcomePageViewModelTest.kt @@ -46,11 +46,11 @@ import com.duckduckgo.app.pixels.AppPixelName.PREONBOARDING_SKIP_ONBOARDING_SHOW import com.duckduckgo.app.pixels.remoteconfig.AndroidBrowserConfigFeature import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.pixels.Pixel -import com.duckduckgo.duckchat.impl.inputscreen.wideevents.InputScreenOnboardingWideEvent import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Unique import com.duckduckgo.appbuildconfig.api.AppBuildConfig import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.duckchat.impl.inputscreen.wideevents.InputScreenOnboardingWideEvent import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory import com.duckduckgo.feature.toggles.api.Toggle import kotlinx.coroutines.test.runTest