Skip to content

Commit 81873cb

Browse files
authored
Duck.ai: Omnibar interactions (#7074)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1174433894299346/task/1211877638725902?focus=true ### Description This PR adds CTA for the main actions in the Duck.ai Omnibar ### Steps to test this PR _Feature Flag enabled_ - [x] Ensure Fullscreen mode is enabled in AI Features - [x] Open Duck.ai - [x] Verify new Omnibar is visible - [x] Tap on the Fire Button - [x] Verify the Bottom Sheet appears (Tab restoration is not implemented, so you’ll see NTP upon restart) - [x] Tap on Tab Switcher - [x] Verify Tab Manager appears (Duck.ai won’t be shown as a tab, not implemented yet) - [x] Tap on the input field - [x] Verify Input Screen appears - [x] Enter a search query - [x] Verify that the browser replaces Duck.ai
1 parent 194e53a commit 81873cb

File tree

13 files changed

+366
-133
lines changed

13 files changed

+366
-133
lines changed

app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt

Lines changed: 0 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -7455,43 +7455,6 @@ class BrowserTabViewModelTest {
74557455
)
74567456
}
74577457

7458-
@Test
7459-
fun whenInputScreenEnabledAndExternalIntentProcessingCompletedThenLaunchInputScreenCommandTriggered() =
7460-
runTest {
7461-
val initialTabId = "initial-tab"
7462-
val initialTab =
7463-
TabEntity(
7464-
tabId = initialTabId,
7465-
url = "https://example.com",
7466-
title = "EX",
7467-
skipHome = false,
7468-
viewed = true,
7469-
position = 0,
7470-
)
7471-
val ntpTabId = "ntp-tab"
7472-
val ntpTab = TabEntity(tabId = ntpTabId, url = null, title = "", skipHome = false, viewed = true, position = 0)
7473-
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
7474-
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
7475-
flowSelectedTab.emit(initialTab)
7476-
7477-
testee.loadData(tabId = ntpTabId, initialUrl = null, skipHome = false, isExternal = false)
7478-
mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow.emit(true)
7479-
mockHasPendingTabLaunchFlow.emit(true)
7480-
7481-
// Switch to a new tab with no URL
7482-
flowSelectedTab.emit(ntpTab)
7483-
7484-
// Complete external intent processing
7485-
mockHasPendingTabLaunchFlow.emit(false)
7486-
7487-
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
7488-
val commands = commandCaptor.allValues
7489-
assertTrue(
7490-
"LaunchInputScreen command should be triggered when external intent processing completes",
7491-
commands.any { it is Command.LaunchInputScreen },
7492-
)
7493-
}
7494-
74957458
@Test
74967459
fun whenInputScreenEnabledAndDuckAiOpenThenLaunchInputScreenCommandSuppressed() =
74977460
runTest {
@@ -7525,51 +7488,6 @@ class BrowserTabViewModelTest {
75257488
)
75267489
}
75277490

7528-
@Test
7529-
fun whenInputScreenEnabledAndDuckAiClosedThenLaunchInputScreenCommandTriggered() =
7530-
runTest {
7531-
val initialTabId = "initial-tab"
7532-
val initialTab =
7533-
TabEntity(
7534-
tabId = initialTabId,
7535-
url = "https://example.com",
7536-
title = "EX",
7537-
skipHome = false,
7538-
viewed = true,
7539-
position = 0,
7540-
)
7541-
val ntpTabId = "ntp-tab"
7542-
val ntpTab =
7543-
TabEntity(
7544-
tabId = ntpTabId,
7545-
url = null,
7546-
title = "",
7547-
skipHome = false,
7548-
viewed = true,
7549-
position = 0,
7550-
)
7551-
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
7552-
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
7553-
flowSelectedTab.emit(initialTab)
7554-
7555-
testee.loadData(tabId = ntpTabId, initialUrl = null, skipHome = false, isExternal = false)
7556-
mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow.emit(true)
7557-
mockHasPendingDuckAiOpenFlow.emit(true)
7558-
7559-
// Switch to a new tab with no URL
7560-
flowSelectedTab.emit(ntpTab)
7561-
7562-
// Close Duck.ai
7563-
mockHasPendingDuckAiOpenFlow.emit(false)
7564-
7565-
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
7566-
val commands = commandCaptor.allValues
7567-
assertTrue(
7568-
"LaunchInputScreen command should be triggered when Duck.ai is closed",
7569-
commands.any { it is Command.LaunchInputScreen },
7570-
)
7571-
}
7572-
75737491
@Test
75747492
fun whenEvaluateSerpLogoStateCalledWithDuckDuckGoUrlAndFeatureEnabledThenExtractSerpLogoCommandIssued() {
75757493
whenever(mockSerpEasterEggLogoToggles.feature()).thenReturn(mockEnabledToggle)

app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@ import android.widget.Toast
3434
import androidx.activity.OnBackPressedCallback
3535
import androidx.activity.result.ActivityResult
3636
import androidx.activity.result.contract.ActivityResultContracts
37+
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
38+
import androidx.activity.viewModels
3739
import androidx.annotation.VisibleForTesting
3840
import androidx.core.view.isVisible
3941
import androidx.core.view.postDelayed
42+
import androidx.lifecycle.Lifecycle
4043
import androidx.lifecycle.Lifecycle.State.STARTED
4144
import androidx.lifecycle.lifecycleScope
4245
import androidx.lifecycle.repeatOnLifecycle
@@ -90,6 +93,7 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter
9093
import com.duckduckgo.app.tabs.TabManagerFeatureFlags
9194
import com.duckduckgo.app.tabs.model.TabEntity
9295
import com.duckduckgo.app.tabs.ui.DefaultSnackbar
96+
import com.duckduckgo.app.tabs.ui.TabSwitcherActivity
9397
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
9498
import com.duckduckgo.autofill.api.emailprotection.EmailProtectionLinkVerifier
9599
import com.duckduckgo.browser.api.ui.BrowserScreens.BookmarksScreenNoParams
@@ -110,6 +114,7 @@ import com.duckduckgo.common.utils.playstore.PlayStoreUtils
110114
import com.duckduckgo.di.scopes.ActivityScope
111115
import com.duckduckgo.duckchat.api.DuckAiFeatureState
112116
import com.duckduckgo.duckchat.api.DuckChat
117+
import com.duckduckgo.duckchat.api.viewmodel.DuckChatSharedViewModel
113118
import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment
114119
import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment.Companion.KEY_DUCK_AI_TABS
115120
import com.duckduckgo.duckchat.impl.ui.DuckChatWebViewFragment.Companion.KEY_DUCK_AI_URL
@@ -249,6 +254,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
249254
}
250255

251256
private val viewModel: BrowserViewModel by bindViewModel()
257+
private val duckChatViewModel: DuckChatSharedViewModel by viewModels()
252258

253259
private var instanceStateBundles: CombinedInstanceState? = null
254260

@@ -360,6 +366,8 @@ open class BrowserActivity : DuckDuckGoActivity() {
360366
viewModel.viewState.observe(this) {
361367
renderer.renderBrowserViewState(it)
362368
}
369+
observeDuckChatSharedCommands()
370+
363371
viewModel.awaitClearDataFinishedNotification()
364372
initializeServiceWorker()
365373

@@ -979,6 +987,45 @@ open class BrowserActivity : DuckDuckGoActivity() {
979987
)
980988
}
981989

990+
private val tabSwitcherActivityResult =
991+
registerForActivityResult(StartActivityForResult()) { result ->
992+
if (result.resultCode == RESULT_OK) {
993+
// Handle any result data if needed
994+
result.data?.let { intent ->
995+
intent.extras?.let { extras ->
996+
val deletedTabIds = extras.getStringArrayList(TabSwitcherActivity.EXTRA_KEY_DELETED_TAB_IDS)
997+
if (!deletedTabIds.isNullOrEmpty()) {
998+
onTabsDeletedInTabSwitcher(deletedTabIds)
999+
}
1000+
}
1001+
}
1002+
}
1003+
}
1004+
1005+
private fun observeDuckChatSharedCommands() {
1006+
lifecycleScope.launch {
1007+
repeatOnLifecycle(Lifecycle.State.STARTED) {
1008+
duckChatViewModel.command.collect { command ->
1009+
when (command) {
1010+
DuckChatSharedViewModel.Command.LaunchFire -> launchFire()
1011+
DuckChatSharedViewModel.Command.LaunchTabSwitcher -> {
1012+
val intent = TabSwitcherActivity.intent(this@BrowserActivity)
1013+
tabSwitcherActivityResult.launch(intent)
1014+
}
1015+
is DuckChatSharedViewModel.Command.SearchRequested -> {
1016+
closeDuckChat()
1017+
currentTab?.submitQuery(command.query)
1018+
}
1019+
1020+
is DuckChatSharedViewModel.Command.OpenTab -> {
1021+
openExistingTab(command.tabId)
1022+
}
1023+
}
1024+
}
1025+
}
1026+
}
1027+
}
1028+
9821029
override fun onAttachFragment(fragment: androidx.fragment.app.Fragment) {
9831030
super.onAttachFragment(fragment)
9841031
hideMockupOmnibar()

app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,6 +1150,7 @@ class BrowserTabFragment :
11501150
}
11511151

11521152
private fun launchInputScreen(query: String) {
1153+
logcat { "Duck.ai: launchInputScreen" }
11531154
val isTopOmnibar = omnibar.omnibarType != OmnibarType.SINGLE_BOTTOM
11541155
val intent =
11551156
globalActivityStarter.startIntent(

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 43 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,6 @@ import kotlinx.coroutines.flow.debounce
380380
import kotlinx.coroutines.flow.distinctUntilChanged
381381
import kotlinx.coroutines.flow.distinctUntilChangedBy
382382
import kotlinx.coroutines.flow.drop
383-
import kotlinx.coroutines.flow.emptyFlow
384383
import kotlinx.coroutines.flow.filter
385384
import kotlinx.coroutines.flow.flatMapLatest
386385
import kotlinx.coroutines.flow.flowOn
@@ -677,6 +676,7 @@ class BrowserTabViewModel @Inject constructor(
677676
}
678677

679678
init {
679+
logcat { "Duck.ai: init" }
680680
initializeViewStates()
681681

682682
fireproofWebsiteState.observeForever(fireproofWebsitesObserver)
@@ -775,33 +775,24 @@ class BrowserTabViewModel @Inject constructor(
775775
}.launchIn(viewModelScope)
776776

777777
// auto-launch input screen for new, empty tabs (New Tab Page)
778-
combine(
779-
externalIntentProcessingState.hasPendingTabLaunch,
780-
externalIntentProcessingState.hasPendingDuckAiOpen,
781-
) { hasPendingTabLaunch, hasPendingDuckAiOpen ->
782-
hasPendingTabLaunch || hasPendingDuckAiOpen
783-
}.flatMapLatest {
784-
if (it) {
785-
// suppress auto-launch while processing external intents (for example, opening links from other apps)
786-
// this prevents the New Tab Page from incorrectly triggering the input screen when the app
787-
// is started via external intent while previously left on NTP
788-
emptyFlow()
789-
} else {
790-
tabRepository.flowSelectedTab
791-
.distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates
792-
.filter { selectedTab ->
793-
// fire event when activating a new, empty tab
794-
// (has no URL and wasn't opened from another tab)
795-
val showInputScreenAutomatically = duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value
796-
val isActiveTab = ::tabId.isInitialized && selectedTab?.tabId == tabId
797-
val isOpenedFromAnotherTab = selectedTab?.sourceTabId != null
798-
showInputScreenAutomatically && isActiveTab && selectedTab?.url.isNullOrBlank() && !isOpenedFromAnotherTab
799-
}.flowOn(dispatchers.main()) // don't use the immediate dispatcher so that the tabId field has a chance to initialize
800-
}
801-
}.onEach {
802-
// whenever an event fires, so the user switched to a new tab page, launch the input screen
803-
command.value = LaunchInputScreen
804-
}.launchIn(viewModelScope)
778+
tabRepository.flowSelectedTab
779+
.distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates
780+
.filter { selectedTab ->
781+
// fire event when activating a new, empty tab
782+
// (has no URL and wasn't opened from another tab)
783+
val showInputScreenAutomatically = duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value
784+
val isActiveTab = ::tabId.isInitialized && selectedTab?.tabId == tabId
785+
val isOpenedFromAnotherTab = selectedTab?.sourceTabId != null
786+
showInputScreenAutomatically && isActiveTab && selectedTab?.url.isNullOrBlank() && !isOpenedFromAnotherTab
787+
}.flowOn(dispatchers.main()) // don't use the immediate dispatcher so that the tabId field has a chance to initialize
788+
.onEach {
789+
val hasPendingTabLaunch = externalIntentProcessingState.hasPendingTabLaunch.value
790+
val hasPendingDuckAiOpen = externalIntentProcessingState.hasPendingDuckAiOpen.value
791+
if (!hasPendingTabLaunch && !hasPendingDuckAiOpen) {
792+
// whenever an event fires, so the user switched to a new tab page, launch the input screen
793+
command.value = LaunchInputScreen
794+
}
795+
}.launchIn(viewModelScope)
805796
}
806797

807798
fun loadData(
@@ -2148,9 +2139,9 @@ class BrowserTabViewModel @Inject constructor(
21482139
currentLoadingViewState().copy(
21492140
isLoading = true,
21502141
trackersAnimationEnabled = true,
2151-
/*We set the progress to 20 so the omnibar starts animating and the user knows we are loading the page.
2152-
* We don't show the browser until the page actually starts loading, to prevent previous sites from briefly
2153-
* showing in case the URL was blocked locally and therefore never started to show*/
2142+
/*We set the progress to 20 so the omnibar starts animating and the user knows we are loading the page.
2143+
* We don't show the browser until the page actually starts loading, to prevent previous sites from briefly
2144+
* showing in case the URL was blocked locally and therefore never started to show*/
21542145
progress = 20,
21552146
url = documentUrlString,
21562147
)
@@ -2988,8 +2979,12 @@ class BrowserTabViewModel @Inject constructor(
29882979
// we hide the keyboard when showing a DialogCta and HomeCta type in the home screen otherwise we show it
29892980
val shouldHideKeyboard =
29902981
cta is HomePanelCta || cta is DaxBubbleCta.DaxPrivacyProCta ||
2991-
duckAiFeatureState.showInputScreen.value || currentBrowserViewState().lastQueryOrigin == QueryOrigin.FromBookmark
2992-
command.value = if (shouldHideKeyboard || (settingsDataStore.omnibarType == OmnibarType.SPLIT && alreadyShownKeyboard)) {
2982+
duckAiFeatureState.showInputScreen.value || currentBrowserViewState().lastQueryOrigin == QueryOrigin.FromBookmark ||
2983+
(settingsDataStore.omnibarType == OmnibarType.SPLIT && alreadyShownKeyboard)
2984+
2985+
logcat { "Duck.ai: shouldHideKeyboard: $shouldHideKeyboard" }
2986+
2987+
command.value = if (shouldHideKeyboard) {
29932988
HideKeyboard
29942989
} else {
29952990
alreadyShownKeyboard = true
@@ -3006,6 +3001,7 @@ class BrowserTabViewModel @Inject constructor(
30063001
is HomePanelCta.AddWidgetAuto, is HomePanelCta.AddWidgetAutoOnboardingExperiment, is HomePanelCta.AddWidgetInstructions -> {
30073002
LaunchAddWidget
30083003
}
3004+
30093005
is OnboardingDaxDialogCta -> onOnboardingCtaOkButtonClicked(cta)
30103006
is DaxBubbleCta -> onDaxBubbleCtaOkButtonClicked(cta)
30113007
is BrokenSitePromptDialogCta -> onBrokenSiteCtaOkButtonClicked(cta)
@@ -3299,7 +3295,10 @@ class BrowserTabViewModel @Inject constructor(
32993295
}
33003296

33013297
@SuppressLint("RequiresFeature", "PostMessageUsage") // it's already checked in isBlobDownloadWebViewFeatureEnabled
3302-
private fun postMessageToConvertBlobToDataUri(webView: WebView, url: String) {
3298+
private fun postMessageToConvertBlobToDataUri(
3299+
webView: WebView,
3300+
url: String,
3301+
) {
33033302
viewModelScope.launch(dispatchers.main()) {
33043303
// main because postMessage is not always safe in another thread
33053304
for ((key, proxies) in fixedReplyProxyMap) {
@@ -3815,6 +3814,7 @@ class BrowserTabViewModel @Inject constructor(
38153814
if (id != null && data != null) {
38163815
webViewCompatWebShare(featureName, method, id, data, onResponse)
38173816
}
3817+
38183818
"permissionsQuery" ->
38193819
if (id != null && data != null) {
38203820
webViewCompatPermissionsQuery(featureName, method, id, data, onResponse)
@@ -3828,6 +3828,7 @@ class BrowserTabViewModel @Inject constructor(
38283828
"screenUnlock" -> screenUnlock()
38293829
}
38303830
}
3831+
38313832
"breakageReporting" ->
38323833
if (data != null) {
38333834
when (method) {
@@ -3836,6 +3837,7 @@ class BrowserTabViewModel @Inject constructor(
38363837
}
38373838
}
38383839
}
3840+
38393841
"messaging" ->
38403842
when (method) {
38413843
"initialPing" -> {
@@ -4381,10 +4383,12 @@ class BrowserTabViewModel @Inject constructor(
43814383
onboardingDesignExperimentManager.isBuckEnrolledAndEnabled() -> {
43824384
command.value = SetBrowserBackgroundColor(getBuckOnboardingExperimentBackgroundColor(lightModeEnabled))
43834385
}
4386+
43844387
onboardingDesignExperimentManager.isBbEnrolledAndEnabled() -> {
43854388
// TODO if BB wins the we should rename the function to SetBubbleDialogBackground
43864389
command.value = Command.SetBubbleDialogBackground(getBBBackgroundResource(lightModeEnabled))
43874390
}
4391+
43884392
else -> {
43894393
command.value = SetBrowserBackground(getBackgroundResource(lightModeEnabled))
43904394
}
@@ -4403,9 +4407,11 @@ class BrowserTabViewModel @Inject constructor(
44034407
onboardingDesignExperimentManager.isBuckEnrolledAndEnabled() -> {
44044408
command.value = SetOnboardingDialogBackgroundColor(getBuckOnboardingExperimentBackgroundColor(lightModeEnabled))
44054409
}
4410+
44064411
onboardingDesignExperimentManager.isBbEnrolledAndEnabled() -> {
44074412
command.value = SetOnboardingDialogBackground(getBBBackgroundResource(lightModeEnabled))
44084413
}
4414+
44094415
else -> {
44104416
command.value = SetOnboardingDialogBackground(getBackgroundResource(lightModeEnabled))
44114417
}
@@ -4470,14 +4476,17 @@ class BrowserTabViewModel @Inject constructor(
44704476
command.value = LaunchPrivacyPro("https://duckduckgo.com/pro?origin=funnel_appmenu_android".toUri())
44714477
"pill"
44724478
}
4479+
44734480
VpnMenuState.NotSubscribedNoPill -> {
44744481
command.value = LaunchPrivacyPro("https://duckduckgo.com/pro?origin=funnel_appmenu_android".toUri())
44754482
"no_pill"
44764483
}
4484+
44774485
is VpnMenuState.Subscribed -> {
44784486
command.value = LaunchVpnManagement
44794487
"subscribed"
44804488
}
4489+
44814490
VpnMenuState.Hidden -> "" // Should not happen as menu item should not be visible
44824491
}
44834492
pixel.fire(AppPixelName.MENU_ACTION_VPN_PRESSED, mapOf(PixelParameter.STATUS to statusParam))

0 commit comments

Comments
 (0)