Skip to content

Commit 0102a96

Browse files
authored
suppress automatic launch of the Input Screen if a new tab request via an Intent (#6785)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1208671518894266/task/1211256901076722?focus=true ### Description Resolves an issue with Input Screen opening unexpectedly when app was launched from an intent. If the app was left on NTP and closed, and then re-opened through a browsable intent (either from another app or from a widget), then before the target page was loaded the NTP could trigger a launch of the Input Screen that would cover up the desired content. ### Steps to test this PR - [x] Set the app as the default browser. - [x] Go to Settings -> AI Features and enable the experimental address bar. - [x] Create a new tab and leave it on the New Tab Page. - [x] Close the app and ensure the process is killed (swipe from history). - [x] Go to anther app that can launch a browsable intent. - [x] Click on a link to open in the browser. - [x] Verify that the app launches, opens a new tab with the desired page, and the Input Screen is not shown. - [x] Open a new tab and verify that the Input Screen is shown automatically. - [x] Go to the home screen and add a search/favorites widget. - [x] Ensure you have some favorites set. - [x] Create a new tab and leave it on the New Tab Page. - [x] Close the app and ensure the process is killed (swipe from history). - [x] Click on a favorite from the widget. - [x] Verify that the app launches, opens the favorite page in a new tab, and the Input Screen is not shown. - [x] Open a new tab and verify that the Input Screen is shown automatically. The original issue was a race condition, so if you want to ensure the fix works, you can apply this diff: ```diff diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index 87b8e1e..9acd0195d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -616,7 +616,7 @@ open class BrowserActivity : DuckDuckGoActivity() { lifecycleScope.launch { viewModel.onOpenShortcut(sharedText) } } else if (intent.getBooleanExtra(LAUNCH_FROM_FAVORITES_WIDGET, false)) { logcat { "Favorite clicked from widget $sharedText" } - lifecycleScope.launch { viewModel.onOpenFavoriteFromWidget(query = sharedText) } + lifecycleScope.launch { delay(5000); viewModel.onOpenFavoriteFromWidget(query = sharedText) } } else if (intent.getBooleanExtra(OPEN_IN_CURRENT_TAB_EXTRA, false)) { logcat(WARN) { "open in current tab requested" } if (currentTab != null) { @@ -646,7 +646,7 @@ open class BrowserActivity : DuckDuckGoActivity() { } else { sharedText } - launchNewTab(query = query, sourceTabId = sourceTabId, skipHome = skipHome) + lifecycleScope.launch { delay(5000); launchNewTab(query = query, sourceTabId = sourceTabId, skipHome = skipHome) } } else { lifecycleScope.launch { viewModel.onOpenInNewTabRequested(sourceTabId = sourceTabId, query = sharedText, skipHome = skipHome) } } ``` This will delay the launch of a new tab when opened from a browsable intent or from favorites in a widget. When applied, performing the test steps above should result in the app opening, showing the NTP for a few seconds, then loading the target page. This confirms that even if NTP appears first, it doesn't prematurely trigger the Input Screen.
1 parent 00d8f4b commit 0102a96

File tree

5 files changed

+330
-16
lines changed

5 files changed

+330
-16
lines changed

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta
149149
import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta.DaxMainNetworkCta
150150
import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta.DaxSerpCta
151151
import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta.DaxTrackersBlockedCta
152+
import com.duckduckgo.app.dispatchers.ExternalIntentProcessingState
152153
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteDao
153154
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity
154155
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepositoryImpl
@@ -447,6 +448,10 @@ class BrowserTabViewModelTest {
447448

448449
private val mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow = MutableStateFlow(false)
449450

451+
private val mockExternalIntentProcessingState: ExternalIntentProcessingState = mock()
452+
453+
private val mockHasPendingTabLaunchFlow = MutableStateFlow(false)
454+
450455
private val mockAppBuildConfig: AppBuildConfig = mock()
451456

452457
private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock()
@@ -651,6 +656,7 @@ class BrowserTabViewModelTest {
651656
whenever(mockDuckAiFeatureState.showPopupMenuShortcut).thenReturn(MutableStateFlow(false))
652657
whenever(mockDuckAiFeatureState.showInputScreen).thenReturn(mockDuckAiFeatureStateInputScreenFlow)
653658
whenever(mockDuckAiFeatureState.showInputScreenAutomaticallyOnNewTab).thenReturn(mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow)
659+
whenever(mockExternalIntentProcessingState.hasPendingTabLaunch).thenReturn(mockHasPendingTabLaunchFlow)
654660
whenever(mockOnboardingDesignExperimentManager.isModifiedControlEnrolledAndEnabled()).thenReturn(false)
655661
whenever(mockOnboardingDesignExperimentManager.isBuckEnrolledAndEnabled()).thenReturn(false)
656662
whenever(mockOnboardingDesignExperimentManager.isBbEnrolledAndEnabled()).thenReturn(false)
@@ -800,6 +806,7 @@ class BrowserTabViewModelTest {
800806
autoCompleteSettings = mockAutoCompleteSettings,
801807
serpEasterEggLogosToggles = mockSerpEasterEggLogoToggles,
802808
nonHttpAppLinkChecker = nonHttpAppLinkChecker,
809+
externalIntentProcessingState = mockExternalIntentProcessingState,
803810
)
804811

805812
testee.loadData("abc", null, false, false)
@@ -6936,6 +6943,58 @@ class BrowserTabViewModelTest {
69366943
)
69376944
}
69386945

6946+
@Test
6947+
fun whenInputScreenEnabledAndExternalIntentProcessingThenLaunchInputScreenCommandSuppressed() = runTest {
6948+
val initialTabId = "initial-tab"
6949+
val initialTab = TabEntity(tabId = initialTabId, url = "https://example.com", title = "EX", skipHome = false, viewed = true, position = 0)
6950+
val ntpTabId = "ntp-tab"
6951+
val ntpTab = TabEntity(tabId = ntpTabId, url = null, title = "", skipHome = false, viewed = true, position = 0)
6952+
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
6953+
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
6954+
flowSelectedTab.emit(initialTab)
6955+
6956+
testee.loadData(tabId = ntpTabId, initialUrl = null, skipHome = false, isExternal = false)
6957+
mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow.emit(true)
6958+
mockHasPendingTabLaunchFlow.emit(true)
6959+
6960+
flowSelectedTab.emit(ntpTab)
6961+
6962+
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
6963+
val commands = commandCaptor.allValues
6964+
assertFalse(
6965+
"LaunchInputScreen command should be suppressed when external intent processing is active",
6966+
commands.any { it is Command.LaunchInputScreen },
6967+
)
6968+
}
6969+
6970+
@Test
6971+
fun whenInputScreenEnabledAndExternalIntentProcessingCompletedThenLaunchInputScreenCommandTriggered() = runTest {
6972+
val initialTabId = "initial-tab"
6973+
val initialTab = TabEntity(tabId = initialTabId, url = "https://example.com", title = "EX", skipHome = false, viewed = true, position = 0)
6974+
val ntpTabId = "ntp-tab"
6975+
val ntpTab = TabEntity(tabId = ntpTabId, url = null, title = "", skipHome = false, viewed = true, position = 0)
6976+
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
6977+
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
6978+
flowSelectedTab.emit(initialTab)
6979+
6980+
testee.loadData(tabId = ntpTabId, initialUrl = null, skipHome = false, isExternal = false)
6981+
mockDuckAiFeatureStateInputScreenOpenAutomaticallyFlow.emit(true)
6982+
mockHasPendingTabLaunchFlow.emit(true)
6983+
6984+
// Switch to a new tab with no URL
6985+
flowSelectedTab.emit(ntpTab)
6986+
6987+
// Complete external intent processing
6988+
mockHasPendingTabLaunchFlow.emit(false)
6989+
6990+
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
6991+
val commands = commandCaptor.allValues
6992+
assertTrue(
6993+
"LaunchInputScreen command should be triggered when external intent processing completes",
6994+
commands.any { it is Command.LaunchInputScreen },
6995+
)
6996+
}
6997+
69396998
@Test
69406999
fun whenEvaluateSerpLogoStateCalledWithDuckDuckGoUrlAndFeatureEnabledThenExtractSerpLogoCommandIssued() {
69417000
whenever(mockSerpEasterEggLogoToggles.feature()).thenReturn(mockEnabledToggle)

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import com.duckduckgo.app.browser.tabs.TabManager.TabModel
6464
import com.duckduckgo.app.browser.tabs.adapter.TabPagerAdapter
6565
import com.duckduckgo.app.browser.webview.RealMaliciousSiteBlockerWebViewIntegration
6666
import com.duckduckgo.app.di.AppCoroutineScope
67+
import com.duckduckgo.app.dispatchers.ExternalIntentProcessingState
6768
import com.duckduckgo.app.downloads.DownloadsScreens.DownloadsScreenNoParams
6869
import com.duckduckgo.app.feedback.ui.common.FeedbackActivity
6970
import com.duckduckgo.app.fire.DataClearer
@@ -171,6 +172,9 @@ open class BrowserActivity : DuckDuckGoActivity() {
171172
@Inject
172173
lateinit var fireButtonStore: FireButtonStore
173174

175+
@Inject
176+
lateinit var externalIntentProcessingState: ExternalIntentProcessingState
177+
174178
@Inject
175179
lateinit var appBuildConfig: AppBuildConfig
176180

@@ -579,6 +583,9 @@ open class BrowserActivity : DuckDuckGoActivity() {
579583

580584
if (launchNewSearch(intent)) {
581585
logcat(WARN) { "new tab requested" }
586+
if (duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value) {
587+
externalIntentProcessingState.onIntentRequestToChangeTab()
588+
}
582589
launchNewTab()
583590
return
584591
}
@@ -597,13 +604,19 @@ open class BrowserActivity : DuckDuckGoActivity() {
597604

598605
val existingTabId = intent.getStringExtra(OPEN_EXISTING_TAB_ID_EXTRA)
599606
if (existingTabId != null) {
607+
if (duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value) {
608+
externalIntentProcessingState.onIntentRequestToChangeTab()
609+
}
600610
openExistingTab(existingTabId)
601611
return
602612
}
603613

604614
val sharedText = intent.intentText
605615
if (sharedText != null) {
606616
closeDuckChat()
617+
if (duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value) {
618+
externalIntentProcessingState.onIntentRequestToChangeTab()
619+
}
607620
if (intent.getBooleanExtra(ShortcutBuilder.SHORTCUT_EXTRA_ARG, false)) {
608621
logcat { "Shortcut opened with url $sharedText" }
609622
lifecycleScope.launch { viewModel.onOpenShortcut(sharedText) }

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

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ import com.duckduckgo.app.cta.ui.DaxBubbleCta
226226
import com.duckduckgo.app.cta.ui.HomePanelCta
227227
import com.duckduckgo.app.cta.ui.OnboardingDaxDialogCta
228228
import com.duckduckgo.app.di.AppCoroutineScope
229+
import com.duckduckgo.app.dispatchers.ExternalIntentProcessingState
229230
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteEntity
230231
import com.duckduckgo.app.fire.fireproofwebsite.data.FireproofWebsiteRepository
231232
import com.duckduckgo.app.fire.fireproofwebsite.ui.AutomaticFireproofSetting.ALWAYS
@@ -376,6 +377,7 @@ import kotlinx.coroutines.flow.debounce
376377
import kotlinx.coroutines.flow.distinctUntilChanged
377378
import kotlinx.coroutines.flow.distinctUntilChangedBy
378379
import kotlinx.coroutines.flow.drop
380+
import kotlinx.coroutines.flow.emptyFlow
379381
import kotlinx.coroutines.flow.filter
380382
import kotlinx.coroutines.flow.flatMapLatest
381383
import kotlinx.coroutines.flow.flowOn
@@ -477,6 +479,7 @@ class BrowserTabViewModel @Inject constructor(
477479
private val onboardingDesignExperimentManager: OnboardingDesignExperimentManager,
478480
private val serpEasterEggLogosToggles: SerpEasterEggLogosToggles,
479481
private val nonHttpAppLinkChecker: NonHttpAppLinkChecker,
482+
private val externalIntentProcessingState: ExternalIntentProcessingState,
480483
) : WebViewClientListener,
481484
EditSavedSiteListener,
482485
DeleteBookmarkListener,
@@ -734,23 +737,30 @@ class BrowserTabViewModel @Inject constructor(
734737
}
735738
.launchIn(viewModelScope)
736739

737-
// observe when user opens a new tab and launch the input screen, if the feature is enabled
738-
tabRepository.flowSelectedTab
739-
.distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates
740-
.filter { selectedTab ->
741-
// fire event when activating a genuinely new tab
742-
// (has no URL and wasn't opened from another tab)
743-
val showInputScreenAutomatically = duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value
744-
val isActiveTab = ::tabId.isInitialized && selectedTab?.tabId == tabId
745-
val isOpenedFromAnotherTab = selectedTab?.sourceTabId != null
746-
showInputScreenAutomatically && isActiveTab && selectedTab?.url.isNullOrBlank() && !isOpenedFromAnotherTab
747-
}
748-
.flowOn(dispatchers.main()) // don't use the immediate dispatcher so that the tabId field has a chance to initialize
749-
.onEach {
750-
// whenever an event fires, so the user switched to a new tab page, launch the input screen
751-
command.value = LaunchInputScreen
740+
// auto-launch input screen for new, empty tabs (New Tab Page)
741+
externalIntentProcessingState.hasPendingTabLaunch.flatMapLatest {
742+
if (it) {
743+
// suppress auto-launch while processing external intents (for example, opening links from other apps)
744+
// this prevents the New Tab Page from incorrectly triggering the input screen when the app
745+
// is started via external intent while previously left on NTP
746+
emptyFlow()
747+
} else {
748+
tabRepository.flowSelectedTab
749+
.distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates
750+
.filter { selectedTab ->
751+
// fire event when activating a new, empty tab
752+
// (has no URL and wasn't opened from another tab)
753+
val showInputScreenAutomatically = duckAiFeatureState.showInputScreenAutomaticallyOnNewTab.value
754+
val isActiveTab = ::tabId.isInitialized && selectedTab?.tabId == tabId
755+
val isOpenedFromAnotherTab = selectedTab?.sourceTabId != null
756+
showInputScreenAutomatically && isActiveTab && selectedTab?.url.isNullOrBlank() && !isOpenedFromAnotherTab
757+
}
758+
.flowOn(dispatchers.main()) // don't use the immediate dispatcher so that the tabId field has a chance to initialize
752759
}
753-
.launchIn(viewModelScope)
760+
}.onEach {
761+
// whenever an event fires, so the user switched to a new tab page, launch the input screen
762+
command.value = LaunchInputScreen
763+
}.launchIn(viewModelScope)
754764
}
755765

756766
fun loadData(
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.dispatchers
18+
19+
import com.duckduckgo.app.di.AppCoroutineScope
20+
import com.duckduckgo.app.tabs.model.TabRepository
21+
import com.duckduckgo.di.scopes.AppScope
22+
import com.squareup.anvil.annotations.ContributesBinding
23+
import dagger.SingleInstanceIn
24+
import javax.inject.Inject
25+
import kotlinx.coroutines.CoroutineScope
26+
import kotlinx.coroutines.flow.MutableStateFlow
27+
import kotlinx.coroutines.flow.StateFlow
28+
import kotlinx.coroutines.flow.asStateFlow
29+
import kotlinx.coroutines.flow.filterNotNull
30+
import kotlinx.coroutines.flow.launchIn
31+
import kotlinx.coroutines.flow.onEach
32+
33+
interface ExternalIntentProcessingState {
34+
val hasPendingTabLaunch: StateFlow<Boolean>
35+
fun onIntentRequestToChangeTab()
36+
}
37+
38+
@ContributesBinding(AppScope::class)
39+
@SingleInstanceIn(AppScope::class)
40+
class ExternalIntentProcessingStateImpl @Inject constructor(
41+
@AppCoroutineScope coroutineScope: CoroutineScope,
42+
tabRepository: TabRepository,
43+
) : ExternalIntentProcessingState {
44+
private val _hasPendingTabLaunch = MutableStateFlow(false)
45+
override val hasPendingTabLaunch: StateFlow<Boolean> = _hasPendingTabLaunch.asStateFlow()
46+
47+
init {
48+
tabRepository.flowSelectedTab.filterNotNull().onEach { tab ->
49+
// if we are switching to a tab that already has a URL, consider tab launch processing complete
50+
if (!tab.url.isNullOrBlank()) {
51+
_hasPendingTabLaunch.value = false
52+
}
53+
}.launchIn(coroutineScope)
54+
}
55+
56+
override fun onIntentRequestToChangeTab() {
57+
_hasPendingTabLaunch.value = true
58+
}
59+
}

0 commit comments

Comments
 (0)