Skip to content

Commit 46bd27c

Browse files
authored
automatically launch the Input Screen when New Tab Page opened (#6604)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1208671518894266/task/1211056807546917?focus=true ### Description With this PR, whenever a New Tab Page is opened, the Input Screen will be launched automatically. Implementing this had some challenges given how complex and fragile the keyboard management implementation is. I couldn't rely on the `BrowserTabFragment#onViewVisible`, which is currently used to auto-focus on the omnibar, because that's getting called whenever the fragment is resumed. So, if the logic to open the Input Screen was part of this function, closing the Input Screen (which is a separate activity) would resume the fragment and open the Input Screen again, resulting in a loop. Instead, I added the logic to observe whenever the tab changes and dispatch a command to launch the Input Screen when user switches to a New Tab Page (this tab has no URL). This logic lives as part of each `BrowserTabFragment` so that the current active fragment, if it's a new tab page, can launch the Input Screen and then listen for its results. ### Steps to test this PR - [x] Install a clean build of the app. - [x] Verify that opening a new tab page focuses on the omnibar automatically. - [x] Open a web page or SERP and verify that switching to a page doesn't focus on the omnibar automatically. - [x] Go to Settings -> AI Features and enable the Experimental Address bar. - [x] Go back to the browser and verify that creating a New Tab opens the Input Screen automatically. - [x] Back out from the Input Screen and verify you can see the New Tab Page. - [x] Go to another tab with a web page or SERP and verify that the Input Screen doesn't open automatically. - [x] Go back to the existing New Tab card in the tab switcher and verify that the Input Screen Opens automatically. - [x] Close the app. - [x] Reopen the app. - [x] Verify that the Input Screen launches automatically (because you're still on a New Tab Page). - [x] Use the fire button and verify that the Input Screen automatically opens only after the fire animation finishes and the process restarts. ### UI changes https://github.com/user-attachments/assets/11bf872d-bc09-46b4-9ecf-77a4a843a064
1 parent f3c2e76 commit 46bd27c

File tree

4 files changed

+147
-14
lines changed

4 files changed

+147
-14
lines changed

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

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,8 @@ class BrowserTabViewModelTest {
446446

447447
private val mockDuckAiFeatureState: DuckAiFeatureState = mock()
448448

449+
private val mockDuckAiFeatureStateInputScreenFlow = MutableStateFlow(false)
450+
449451
private val mockAppBuildConfig: AppBuildConfig = mock()
450452

451453
private val mockDuckDuckGoUrlDetector: DuckDuckGoUrlDetector = mock()
@@ -584,6 +586,7 @@ class BrowserTabViewModelTest {
584586
private val SHORT_EXAMPLE_URL = "example.com"
585587

586588
private val selectedTab = TabEntity("TAB_ID", EXAMPLE_URL, position = 0, sourceTabId = "TAB_ID_SOURCE")
589+
private val flowSelectedTab = MutableStateFlow(selectedTab)
587590

588591
private var isFullSiteAddressEnabled = true
589592

@@ -620,7 +623,7 @@ class BrowserTabViewModelTest {
620623
whenever(mockDismissedCtaDao.dismissedCtas()).thenReturn(dismissedCtaDaoChannel.consumeAsFlow())
621624
whenever(mockTabRepository.flowTabs).thenReturn(flowOf(emptyList()))
622625
whenever(mockTabRepository.getTabs()).thenReturn(emptyList())
623-
whenever(mockTabRepository.flowSelectedTab).thenReturn(flowOf(selectedTab))
626+
whenever(mockTabRepository.flowSelectedTab).thenReturn(flowSelectedTab)
624627
whenever(mockTabRepository.liveTabs).thenReturn(tabsLiveData)
625628
whenever(mockEmailManager.signedInFlow()).thenReturn(emailStateFlow.asStateFlow())
626629
whenever(mockSavedSitesRepository.getFavorites()).thenReturn(favoriteListFlow.consumeAsFlow())
@@ -641,7 +644,7 @@ class BrowserTabViewModelTest {
641644
whenever(mockToggleReports.shouldPrompt()).thenReturn(false)
642645
whenever(subscriptions.isEligible()).thenReturn(false)
643646
whenever(mockDuckAiFeatureState.showPopupMenuShortcut).thenReturn(MutableStateFlow(false))
644-
whenever(mockDuckAiFeatureState.showInputScreen).thenReturn(MutableStateFlow(false))
647+
whenever(mockDuckAiFeatureState.showInputScreen).thenReturn(mockDuckAiFeatureStateInputScreenFlow)
645648
whenever(mockOnboardingDesignExperimentManager.isModifiedControlEnrolledAndEnabled()).thenReturn(false)
646649
whenever(mockOnboardingDesignExperimentManager.isBuckEnrolledAndEnabled()).thenReturn(false)
647650
whenever(mockOnboardingDesignExperimentManager.isBbEnrolledAndEnabled()).thenReturn(false)
@@ -6978,6 +6981,98 @@ class BrowserTabViewModelTest {
69786981
verify(mockOnboardingDesignExperimentManager).fireFireButtonClickedFromOnboardingPixel()
69796982
}
69806983

6984+
@Test
6985+
fun whenInputScreenEnabledAndSwitchToNewTabThenLaunchInputScreenCommandTriggered() = runTest {
6986+
val initialTabId = "initial-tab"
6987+
val initialTab = TabEntity(tabId = initialTabId, url = "https://example.com", title = "EX", skipHome = false, viewed = true, position = 0)
6988+
val ntpTabId = "ntp-tab"
6989+
val ntpTab = TabEntity(tabId = ntpTabId, url = null, title = "", skipHome = false, viewed = true, position = 0)
6990+
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
6991+
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
6992+
flowSelectedTab.emit(initialTab)
6993+
6994+
testee.loadData(ntpTabId, null, false, false)
6995+
mockDuckAiFeatureStateInputScreenFlow.emit(true)
6996+
6997+
flowSelectedTab.emit(ntpTab)
6998+
6999+
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
7000+
val commands = commandCaptor.allValues
7001+
assertTrue(
7002+
"LaunchInputScreen command should be triggered for null URL tab",
7003+
commands.any { it is Command.LaunchInputScreen },
7004+
)
7005+
}
7006+
7007+
@Test
7008+
fun whenInputScreenDisabledAndSwitchToNewTabThenLaunchInputScreenCommandTriggered() = runTest {
7009+
val initialTabId = "initial-tab"
7010+
val initialTab = TabEntity(tabId = initialTabId, url = "https://example.com", title = "EX", skipHome = false, viewed = true, position = 0)
7011+
val ntpTabId = "ntp-tab"
7012+
val ntpTab = TabEntity(tabId = ntpTabId, url = null, title = "", skipHome = false, viewed = true, position = 0)
7013+
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
7014+
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
7015+
flowSelectedTab.emit(initialTab)
7016+
7017+
testee.loadData(ntpTabId, null, false, false)
7018+
mockDuckAiFeatureStateInputScreenFlow.emit(false)
7019+
7020+
flowSelectedTab.emit(ntpTab)
7021+
7022+
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
7023+
val commands = commandCaptor.allValues
7024+
assertFalse(
7025+
"LaunchInputScreen command should NOT be triggered when input screen is disabled",
7026+
commands.any { it is Command.LaunchInputScreen },
7027+
)
7028+
}
7029+
7030+
@Test
7031+
fun whenInputScreenEnabledAndSwitchToTabWithUrlThenLaunchInputScreenCommandNotTriggered() = runTest {
7032+
val initialTabId = "initial-tab"
7033+
val initialTab = TabEntity(tabId = initialTabId, url = "https://example.com", title = "EX", skipHome = false, viewed = true, position = 0)
7034+
val targetTabId = "target-tab"
7035+
val targetTab = TabEntity(tabId = targetTabId, url = "https://duckduckgo.com", title = "DDG", skipHome = false, viewed = true, position = 0)
7036+
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
7037+
whenever(mockTabRepository.getTab(targetTabId)).thenReturn(targetTab)
7038+
flowSelectedTab.emit(initialTab)
7039+
7040+
testee.loadData(targetTabId, null, false, false)
7041+
mockDuckAiFeatureStateInputScreenFlow.emit(true)
7042+
7043+
flowSelectedTab.emit(targetTab)
7044+
7045+
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
7046+
val commands = commandCaptor.allValues
7047+
assertFalse(
7048+
"LaunchInputScreen command should NOT be triggered when switching to tab with existing URL",
7049+
commands.any { it is Command.LaunchInputScreen },
7050+
)
7051+
}
7052+
7053+
@Test
7054+
fun whenInputScreenEnabledAndSwitchToNewTabThatManagedByAnotherViewModelThenLaunchInputScreenCommandNotTriggered() = runTest {
7055+
val initialTabId = "initial-tab"
7056+
val initialTab = TabEntity(tabId = initialTabId, url = "https://example.com", title = "EX", skipHome = false, viewed = true, position = 0)
7057+
val ntpTabId = "ntp-tab"
7058+
val ntpTab = TabEntity(tabId = ntpTabId, url = null, title = "", skipHome = false, viewed = true, position = 0)
7059+
whenever(mockTabRepository.getTab(initialTabId)).thenReturn(initialTab)
7060+
whenever(mockTabRepository.getTab(ntpTabId)).thenReturn(ntpTab)
7061+
flowSelectedTab.emit(initialTab)
7062+
7063+
testee.loadData(initialTabId, null, false, false)
7064+
mockDuckAiFeatureStateInputScreenFlow.emit(true)
7065+
7066+
flowSelectedTab.emit(ntpTab)
7067+
7068+
verify(mockCommandObserver, atLeastOnce()).onChanged(commandCaptor.capture())
7069+
val commands = commandCaptor.allValues
7070+
assertFalse(
7071+
"LaunchInputScreen command should NOT be triggered when switching to new tab that's managed by another view model",
7072+
commands.any { it is Command.LaunchInputScreen },
7073+
)
7074+
}
7075+
69817076
private fun aCredential(): LoginCredentials {
69827077
return LoginCredentials(domain = null, username = null, password = null)
69837078
}

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,21 +1100,25 @@ class BrowserTabFragment :
11001100

11011101
private fun configureInputScreenLauncher() {
11021102
omnibar.configureInputScreenLaunchListener { query ->
1103-
val intent = globalActivityStarter.startIntent(
1104-
requireContext(),
1105-
InputScreenActivityParams(query = query),
1106-
)
1107-
val enterTransition = browserAndInputScreenTransitionProvider.getInputScreenEnterAnimation()
1108-
val exitTransition = browserAndInputScreenTransitionProvider.getBrowserExitAnimation()
1109-
val options = ActivityOptionsCompat.makeCustomAnimation(
1110-
requireActivity(),
1111-
enterTransition,
1112-
exitTransition,
1113-
)
1114-
inputScreenLauncher.launch(intent, options)
1103+
launchInputScreen(query)
11151104
}
11161105
}
11171106

1107+
private fun launchInputScreen(query: String) {
1108+
val intent = globalActivityStarter.startIntent(
1109+
requireContext(),
1110+
InputScreenActivityParams(query = query),
1111+
)
1112+
val enterTransition = browserAndInputScreenTransitionProvider.getInputScreenEnterAnimation()
1113+
val exitTransition = browserAndInputScreenTransitionProvider.getBrowserExitAnimation()
1114+
val options = ActivityOptionsCompat.makeCustomAnimation(
1115+
requireActivity(),
1116+
enterTransition,
1117+
exitTransition,
1118+
)
1119+
inputScreenLauncher.launch(intent, options)
1120+
}
1121+
11181122
private fun configureNavigationBar() {
11191123
val observer = object : BrowserNavigationBarObserver {
11201124
override fun onFireButtonClicked() {
@@ -2202,6 +2206,12 @@ class BrowserTabFragment :
22022206

22032207
is Command.StartTrackersExperimentShieldPopAnimation -> showTrackersExperimentShieldPopAnimation()
22042208
is Command.RefreshOmnibar -> renderer.refreshOmnibar()
2209+
is Command.LaunchInputScreen -> {
2210+
// if the fire button is used, prevent automatically launching the input screen until the process reloads
2211+
if ((requireActivity() as? BrowserActivity)?.isDataClearingInProgress == false) {
2212+
launchInputScreen(query = "")
2213+
}
2214+
}
22052215
else -> {
22062216
// NO OP
22072217
}

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import com.duckduckgo.app.browser.commands.Command.LaunchAddWidget
105105
import com.duckduckgo.app.browser.commands.Command.LaunchAutofillSettings
106106
import com.duckduckgo.app.browser.commands.Command.LaunchBookmarksActivity
107107
import com.duckduckgo.app.browser.commands.Command.LaunchFireDialogFromOnboardingDialog
108+
import com.duckduckgo.app.browser.commands.Command.LaunchInputScreen
108109
import com.duckduckgo.app.browser.commands.Command.LaunchNewTab
109110
import com.duckduckgo.app.browser.commands.Command.LaunchPopupMenu
110111
import com.duckduckgo.app.browser.commands.Command.LaunchPrivacyPro
@@ -374,8 +375,11 @@ import kotlinx.coroutines.flow.catch
374375
import kotlinx.coroutines.flow.combine
375376
import kotlinx.coroutines.flow.debounce
376377
import kotlinx.coroutines.flow.distinctUntilChanged
378+
import kotlinx.coroutines.flow.distinctUntilChangedBy
377379
import kotlinx.coroutines.flow.drop
380+
import kotlinx.coroutines.flow.filter
378381
import kotlinx.coroutines.flow.flatMapLatest
382+
import kotlinx.coroutines.flow.flowOf
379383
import kotlinx.coroutines.flow.flowOn
380384
import kotlinx.coroutines.flow.launchIn
381385
import kotlinx.coroutines.flow.map
@@ -394,6 +398,7 @@ import org.json.JSONObject
394398

395399
private const val SCAM_PROTECTION_REPORT_ERROR_URL = "https://duckduckgo.com/malicious-site-protection/report-error?url="
396400

401+
@OptIn(ExperimentalCoroutinesApi::class)
397402
@ContributesViewModel(FragmentScope::class)
398403
class BrowserTabViewModel @Inject constructor(
399404
private val statisticsUpdater: StatisticsUpdater,
@@ -727,6 +732,28 @@ class BrowserTabViewModel @Inject constructor(
727732
browserViewState.value = currentBrowserViewState().copy(showSelectDefaultBrowserMenuItem = it)
728733
}
729734
.launchIn(viewModelScope)
735+
736+
// observe when user open a new tab page and launch the input screen
737+
duckAiFeatureState.showInputScreen
738+
.flatMapLatest { inputScreenEnabled ->
739+
if (inputScreenEnabled) {
740+
tabRepository.flowSelectedTab
741+
.distinctUntilChangedBy { selectedTab -> selectedTab?.tabId } // only observe when the tab changes and ignore further updates
742+
.filter { selectedTab ->
743+
// if the tab managed by this view model has just been activated, and it's a new tab (it has no URL), then fire an event
744+
val isActiveTab = selectedTab?.tabId == tabId
745+
isActiveTab && selectedTab?.url.isNullOrBlank()
746+
}
747+
} else {
748+
flowOf()
749+
}
750+
}
751+
.flowOn(dispatchers.main()) // don't use the immediate dispatcher so that the tabId field has a chance to initialize
752+
.onEach {
753+
// whenever an event fires, so the user switched to a new tab page, launch the input screen
754+
command.value = LaunchInputScreen
755+
}
756+
.launchIn(viewModelScope)
730757
}
731758

732759
fun loadData(

app/src/main/java/com/duckduckgo/app/browser/commands/Command.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,4 +279,5 @@ sealed class Command {
279279
data object LaunchBookmarksActivity : Command()
280280
data object StartTrackersExperimentShieldPopAnimation : Command()
281281
data object RefreshOmnibar : Command()
282+
data object LaunchInputScreen : Command()
282283
}

0 commit comments

Comments
 (0)