From b17957c6591099bfa987c91582cfff82217adb39 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Jun 2026 13:28:18 +0200 Subject: [PATCH 01/10] Add EscapeHatchTargetResolver for cross-mode hatch target --- new-tab-page/new-tab-page-api/build.gradle | 1 + .../api/EscapeHatchTargetResolver.kt | 40 +++++++ new-tab-page/new-tab-page-impl/build.gradle | 1 + .../impl/RealEscapeHatchTargetResolver.kt | 54 +++++++++ .../impl/RealEscapeHatchTargetResolverTest.kt | 109 ++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 new-tab-page/new-tab-page-api/src/main/java/com/duckduckgo/newtabpage/api/EscapeHatchTargetResolver.kt create mode 100644 new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolver.kt create mode 100644 new-tab-page/new-tab-page-impl/src/test/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolverTest.kt diff --git a/new-tab-page/new-tab-page-api/build.gradle b/new-tab-page/new-tab-page-api/build.gradle index 2cda5783dec9..f4ab91d9aab8 100644 --- a/new-tab-page/new-tab-page-api/build.gradle +++ b/new-tab-page/new-tab-page-api/build.gradle @@ -28,4 +28,5 @@ android { dependencies { implementation KotlinX.coroutines.core implementation project(':common-utils') + implementation project(':browser-mode-api') } diff --git a/new-tab-page/new-tab-page-api/src/main/java/com/duckduckgo/newtabpage/api/EscapeHatchTargetResolver.kt b/new-tab-page/new-tab-page-api/src/main/java/com/duckduckgo/newtabpage/api/EscapeHatchTargetResolver.kt new file mode 100644 index 000000000000..01c66f09a8db --- /dev/null +++ b/new-tab-page/new-tab-page-api/src/main/java/com/duckduckgo/newtabpage/api/EscapeHatchTargetResolver.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 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.newtabpage.api + +import com.duckduckgo.browsermode.api.BrowserMode + +/** + * Resolves which tab the NTP escape hatch should offer to return to. + * + * The hatch only ever renders in Regular mode, so an [EscapeHatchTarget] with [EscapeHatchTarget.mode] + * == [BrowserMode.FIRE] also implies that tapping the hatch must switch into Fire mode to open it. + */ +interface EscapeHatchTargetResolver { + /** Returns the tab to offer, or null when there is none (including whenever the app is in Fire mode). */ + suspend fun resolve(): EscapeHatchTarget? +} + +/** + * @param tabId id of the tab to return to. + * @param mode the [BrowserMode] that owns the tab (the database it lives in); the mode the browser + * must be in to open it. + */ +data class EscapeHatchTarget( + val tabId: String, + val mode: BrowserMode, +) diff --git a/new-tab-page/new-tab-page-impl/build.gradle b/new-tab-page/new-tab-page-impl/build.gradle index a0329982ea87..51261988bd05 100644 --- a/new-tab-page/new-tab-page-impl/build.gradle +++ b/new-tab-page/new-tab-page-impl/build.gradle @@ -27,6 +27,7 @@ dependencies { ksp project(':anvil-ksp') implementation project(":new-tab-page-api") implementation project(':browser-api') + implementation project(':browser-mode-api') implementation project(':common-utils') implementation project(':saved-sites-api') implementation project(':navigation-api') diff --git a/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolver.kt b/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolver.kt new file mode 100644 index 000000000000..8852f24e88cf --- /dev/null +++ b/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolver.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026 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.newtabpage.impl + +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider +import com.duckduckgo.browsermode.api.BrowserModeStateHolder +import com.duckduckgo.di.scopes.AppScope +import com.duckduckgo.newtabpage.api.EscapeHatchTarget +import com.duckduckgo.newtabpage.api.EscapeHatchTargetResolver +import com.squareup.anvil.annotations.ContributesBinding +import dagger.SingleInstanceIn +import java.time.LocalDateTime +import javax.inject.Inject + +@SingleInstanceIn(AppScope::class) +@ContributesBinding(AppScope::class) +class RealEscapeHatchTargetResolver @Inject constructor( + private val browserModeStateHolder: BrowserModeStateHolder, + private val tabRepositoryProvider: BrowserModeDataProvider, +) : EscapeHatchTargetResolver { + + override suspend fun resolve(): EscapeHatchTarget? { + // No hatch in Fire mode — also defends against a stale process-global isAfterIdleReturn that + // was set in Regular before the user switched to Fire. + if (browserModeStateHolder.currentMode.value == BrowserMode.FIRE) return null + + // In Regular mode (cold or hot) the hatch offers the globally most-recently-used tab: take + // each mode's most-recent tab (tagged with its owning mode) and keep whichever was accessed + // last. On a hot resume that is the just-used Regular tab; only on a cold start can a Fire + // tab win. REGULAR is listed first, so it wins ties. + val candidates = listOf(BrowserMode.REGULAR, BrowserMode.FIRE).mapNotNull { mode -> + tabRepositoryProvider.forMode(mode).getLastAccessedTab()?.let { it to mode } + } + val (tab, mode) = candidates.maxByOrNull { (candidate, _) -> candidate.lastAccessTime ?: LocalDateTime.MIN } + ?: return null + return EscapeHatchTarget(tab.tabId, mode) + } +} diff --git a/new-tab-page/new-tab-page-impl/src/test/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolverTest.kt b/new-tab-page/new-tab-page-impl/src/test/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolverTest.kt new file mode 100644 index 000000000000..77841f695700 --- /dev/null +++ b/new-tab-page/new-tab-page-impl/src/test/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolverTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026 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.newtabpage.impl + +import com.duckduckgo.app.tabs.model.TabEntity +import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider +import com.duckduckgo.browsermode.api.BrowserModeStateHolder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.time.LocalDateTime + +class RealEscapeHatchTargetResolverTest { + + private val regularRepo: TabRepository = mock() + private val fireRepo: TabRepository = mock() + private val modeFlow = MutableStateFlow(BrowserMode.REGULAR) + private val stateHolder: BrowserModeStateHolder = mock { on { currentMode } doReturn modeFlow } + + private val provider = object : BrowserModeDataProvider { + override fun forMode(mode: BrowserMode): TabRepository = + if (mode == BrowserMode.FIRE) fireRepo else regularRepo + } + + private val testee = RealEscapeHatchTargetResolver(stateHolder, provider) + + private fun tab(id: String, accessed: LocalDateTime?) = + TabEntity(tabId = id, url = "https://$id.com", title = id, lastAccessTime = accessed) + + @Test + fun whenFireModeThenReturnsNull() = runTest { + modeFlow.value = BrowserMode.FIRE + + assertNull(testee.resolve()) + } + + @Test + fun whenFireTabMoreRecentThenReturnsFireTarget() = runTest { + whenever(regularRepo.getLastAccessedTab()).thenReturn(tab("reg", LocalDateTime.of(2026, 6, 1, 9, 0))) + whenever(fireRepo.getLastAccessedTab()).thenReturn(tab("fire", LocalDateTime.of(2026, 6, 1, 10, 0))) + + val target = testee.resolve() + + assertEquals("fire", target?.tabId) + assertEquals(BrowserMode.FIRE, target?.mode) + } + + @Test + fun whenRegularTabMoreRecentThenReturnsRegularTarget() = runTest { + whenever(regularRepo.getLastAccessedTab()).thenReturn(tab("reg", LocalDateTime.of(2026, 6, 1, 11, 0))) + whenever(fireRepo.getLastAccessedTab()).thenReturn(tab("fire", LocalDateTime.of(2026, 6, 1, 10, 0))) + + val target = testee.resolve() + + assertEquals("reg", target?.tabId) + assertEquals(BrowserMode.REGULAR, target?.mode) + } + + @Test + fun whenOnlyFireTabThenReturnsFireTarget() = runTest { + whenever(regularRepo.getLastAccessedTab()).thenReturn(null) + whenever(fireRepo.getLastAccessedTab()).thenReturn(tab("fire", LocalDateTime.of(2026, 6, 1, 10, 0))) + + val target = testee.resolve() + + assertEquals("fire", target?.tabId) + assertEquals(BrowserMode.FIRE, target?.mode) + } + + @Test + fun whenOnlyRegularTabThenReturnsRegularTarget() = runTest { + whenever(regularRepo.getLastAccessedTab()).thenReturn(tab("reg", LocalDateTime.of(2026, 6, 1, 9, 0))) + whenever(fireRepo.getLastAccessedTab()).thenReturn(null) + + val target = testee.resolve() + + assertEquals("reg", target?.tabId) + assertEquals(BrowserMode.REGULAR, target?.mode) + } + + @Test + fun whenNoTabsThenReturnsNull() = runTest { + whenever(regularRepo.getLastAccessedTab()).thenReturn(null) + whenever(fireRepo.getLastAccessedTab()).thenReturn(null) + + assertNull(testee.resolve()) + } +} From 9af0f8a01e848dc6c5f919159d4cda0e43e686e2 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Jun 2026 14:18:42 +0200 Subject: [PATCH 02/10] Open a Fire NTP without the escape hatch on Fire-mode inactivity resume --- .../ShowOnAppLaunchOptionHandler.kt | 23 ++++-- .../ShowOnAppLaunchOptionHandlerImplTest.kt | 76 ++++++++++++++++++- 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt index d38832ea6c8a..a4f2ae5eec7b 100644 --- a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandler.kt @@ -27,7 +27,9 @@ import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.browser.api.wideevents.BrowserInteractionsPlugin -import com.duckduckgo.browsermode.api.RegularMode +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider +import com.duckduckgo.browsermode.api.BrowserModeStateHolder import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.isHttpOrHttps import com.duckduckgo.common.utils.plugins.PluginPoint @@ -53,11 +55,12 @@ interface ShowOnAppLaunchOptionHandler { class ShowOnAppLaunchOptionHandlerImpl @Inject constructor( private val dispatchers: DispatcherProvider, private val showOnAppLaunchOptionDataStore: ShowOnAppLaunchOptionDataStore, - @RegularMode private val tabRepository: TabRepository, private val ntpAfterIdleManager: NtpAfterIdleManager, private val settingsDataStore: SettingsDataStore, private val systemAutofillEngagement: SystemAutofillEngagement, private val browserInteractionsPlugins: PluginPoint, + private val tabRepositoryProvider: BrowserModeDataProvider, + private val browserModeStateHolder: BrowserModeStateHolder, ) : ShowOnAppLaunchOptionHandler { override suspend fun handleAfterInactivityOption(wasIdle: Boolean) { @@ -73,26 +76,31 @@ class ShowOnAppLaunchOptionHandlerImpl @Inject constructor( val option = showOnAppLaunchOptionDataStore.optionFlow.first() logcat { "FirstScreen: showing $option on app launch" } + val currentMode = browserModeStateHolder.currentMode.value + when (option) { LastOpenedTab -> { + if (currentMode != BrowserMode.REGULAR) return if (fromInactivity) { // Skip when the visible tab is a blank NTP — the NTP path classifies that case. - val selectedTab = tabRepository.getSelectedTab() + val selectedTab = tabRepositoryProvider.forMode(BrowserMode.REGULAR).getSelectedTab() if (selectedTab != null && !selectedTab.url.isNullOrBlank()) { browserInteractionsPlugins.getPlugins().forEach { it.onLutShownAfterIdle() } } } } NewTabPage -> { - val selectedTab = tabRepository.getSelectedTab() + val repo = tabRepositoryProvider.forMode(currentMode) + val selectedTab = repo.getSelectedTab() if (selectedTab == null || !selectedTab.url.isNullOrBlank()) { - if (fromInactivity) { + // The hatch (after-idle classification) only ever applies in Regular mode. + if (fromInactivity && currentMode == BrowserMode.REGULAR) { // Set pendingAfterIdle BEFORE adding the tab so BrowserViewModel's // flowSelectedTab emit consumes it via onNtpShown for the new NTP. ntpAfterIdleManager.onIdleReturnTriggered() notifyAutofillIdleReturn("new_tab_page") } - tabRepository.add() + repo.add() } // When the user is already on an NTP we deliberately don't trigger here: // - If the prior session classified this NTP as auto-initiated, NtpAfterIdleManager @@ -101,6 +109,8 @@ class ShowOnAppLaunchOptionHandlerImpl @Inject constructor( // manually-opened new tab), incorrectly classifying it as auto-initiated. } is SpecificPage -> { + // Normal-mode launch preference — never navigate the Fire session. + if (currentMode != BrowserMode.REGULAR) return if (fromInactivity) { notifyAutofillIdleReturn("specific_page") } @@ -132,6 +142,7 @@ class ShowOnAppLaunchOptionHandlerImpl @Inject constructor( } private suspend fun handleSpecificPageOption(option: SpecificPage) { + val tabRepository = tabRepositoryProvider.forMode(BrowserMode.REGULAR) val userUri = option.url.toUri() val resolvedUri = option.resolvedUrl?.toUri() diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt index e27840bf00ef..0b98c3406c1e 100644 --- a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/ShowOnAppLaunchOptionHandlerImplTest.kt @@ -32,11 +32,15 @@ import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.app.tabs.model.TabSwitcherData import com.duckduckgo.app.tabs.model.TabSwitcherData.LayoutType import com.duckduckgo.browser.api.wideevents.BrowserInteractionsPlugin +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider +import com.duckduckgo.browsermode.api.BrowserModeStateHolder import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.common.utils.plugins.PluginPoint import com.duckduckgo.newtabpage.api.NtpAfterIdleManager import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOf @@ -49,6 +53,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -63,6 +68,9 @@ class ShowOnAppLaunchOptionHandlerImplTest { private lateinit var fakeDataStore: FakeShowOnAppLaunchOptionDataStore private lateinit var fakeTabRepository: TabRepository + private lateinit var fakeFireTabRepository: TabRepository + private val modeFlow = MutableStateFlow(BrowserMode.REGULAR) + private val browserModeStateHolder: BrowserModeStateHolder = mock { on { currentMode } doReturn modeFlow } private val ntpAfterIdleManager: NtpAfterIdleManager = mock() private val settingsDataStore: SettingsDataStore = mock() private val systemAutofillEngagement: SystemAutofillEngagement = mock() @@ -73,15 +81,21 @@ class ShowOnAppLaunchOptionHandlerImplTest { fun setup() { fakeDataStore = FakeShowOnAppLaunchOptionDataStore() fakeTabRepository = FakeTabRepository() + fakeFireTabRepository = FakeTabRepository() + val provider = object : BrowserModeDataProvider { + override fun forMode(mode: BrowserMode): TabRepository = + if (mode == BrowserMode.FIRE) fakeFireTabRepository else fakeTabRepository + } whenever(settingsDataStore.userSelectedIdleThresholdSeconds).thenReturn(null) testee = ShowOnAppLaunchOptionHandlerImpl( dispatcherProvider, fakeDataStore, - fakeTabRepository, ntpAfterIdleManager, settingsDataStore, systemAutofillEngagement, browserInteractionsPlugins, + provider, + browserModeStateHolder, ) } @@ -1048,6 +1062,66 @@ class ShowOnAppLaunchOptionHandlerImplTest { assertNull(fakeDataStore.resolvedPageUrl) } + @Test + fun whenFireModeAndOptionIsNewTabPageInactivityThenAddsFireTabWithoutTriggeringHatch() = runTest { + modeFlow.value = BrowserMode.FIRE + fakeDataStore.setShowOnAppLaunchOption(NewTabPage) + (fakeFireTabRepository as FakeTabRepository).selectedTab = + TabEntity(tabId = "f1", url = "https://example.com", position = 0) + + testee.handleAfterInactivityOption(wasIdle = true) + + fakeFireTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == "") + } + assertTrue(fakeTabRepository.flowTabs.firstOrNull()?.isEmpty() == true) + verify(ntpAfterIdleManager, never()).onIdleReturnTriggered() + } + + @Test + fun whenRegularModeAndOptionIsNewTabPageInactivityThenTriggersHatchAndAddsRegularTab() = runTest { + modeFlow.value = BrowserMode.REGULAR + fakeDataStore.setShowOnAppLaunchOption(NewTabPage) + (fakeTabRepository as FakeTabRepository).selectedTab = + TabEntity(tabId = "1", url = "https://example.com", position = 0) + + testee.handleAfterInactivityOption(wasIdle = true) + + fakeTabRepository.flowTabs.test { + val tabs = awaitItem() + awaitComplete() + assertTrue(tabs.size == 1) + assertTrue(tabs.last().url == "") + } + verify(ntpAfterIdleManager).onIdleReturnTriggered() + } + + @Test + fun whenFireModeAndOptionIsSpecificPageThenNoOp() = runTest { + modeFlow.value = BrowserMode.FIRE + fakeDataStore.setShowOnAppLaunchOption(SpecificPage("https://example.com")) + + testee.handleAfterInactivityOption(wasIdle = true) + + assertTrue(fakeFireTabRepository.flowTabs.firstOrNull()?.isEmpty() == true) + assertTrue(fakeTabRepository.flowTabs.firstOrNull()?.isEmpty() == true) + } + + @Test + fun whenFireModeAndOptionIsLastOpenedTabThenNoOp() = runTest { + modeFlow.value = BrowserMode.FIRE + fakeDataStore.setShowOnAppLaunchOption(LastOpenedTab) + + testee.handleAfterInactivityOption(wasIdle = true) + + verify(browserInteractionsPlugins, never()).getPlugins() + assertTrue(fakeFireTabRepository.flowTabs.firstOrNull()?.isEmpty() == true) + assertTrue(fakeTabRepository.flowTabs.firstOrNull()?.isEmpty() == true) + } + private class FakeTabRepository : TabRepository { private val tabs = mutableMapOf() From 98f26379e14c585d46f46c0501a8a670d6440963 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Jun 2026 15:06:43 +0200 Subject: [PATCH 03/10] Add OpenExistingTab pending action and openExistingTabInMode for cross-mode tab opening Co-Authored-By: Claude Opus 4.8 (1M context) --- .../duckduckgo/app/browser/BrowserActivity.kt | 4 ++++ .../app/browser/PendingModeSwitch.kt | 10 ++++++++ .../app/browser/PendingModeSwitchTest.kt | 23 +++++++++++++++++++ 3 files changed, 37 insertions(+) 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 5d36ddeaf718..4eb341c35899 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -1773,6 +1773,7 @@ open class BrowserActivity : DuckDuckGoActivity() { action.skipHome, action.isExternal, ) + is PendingAction.OpenExistingTab -> openExistingTab(action.tabId) } } @@ -1832,6 +1833,9 @@ open class BrowserActivity : DuckDuckGoActivity() { } } + fun openExistingTabInMode(mode: BrowserMode, tabId: String) = + switchModeThen(mode, PendingAction.OpenExistingTab(tabId)) + fun onEditModeChanged(isInEditMode: Boolean) { viewModel.onOmnibarEditModeChanged(isInEditMode) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt b/app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt index c399cccfd1ab..d124bd52efa1 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt @@ -39,6 +39,7 @@ internal sealed class PendingAction { val skipHome: Boolean, val isExternal: Boolean, ) : PendingAction() + data class OpenExistingTab(val tabId: String) : PendingAction() } /** A [PendingAction] paired with the [BrowserMode] it must run in. */ @@ -61,6 +62,10 @@ internal fun PendingModeSwitch.toBundle(): Bundle { bundle.putBoolean(KEY_SKIP_HOME, pendingAction.skipHome) bundle.putBoolean(KEY_IS_EXTERNAL, pendingAction.isExternal) } + is PendingAction.OpenExistingTab -> { + bundle.putString(KEY_ACTION, ACTION_OPEN_EXISTING_TAB) + bundle.putString(KEY_EXISTING_TAB_ID, pendingAction.tabId) + } } return bundle } @@ -78,6 +83,9 @@ internal fun Bundle.toPendingModeSwitch(): PendingModeSwitch? { skipHome = getBoolean(KEY_SKIP_HOME), isExternal = getBoolean(KEY_IS_EXTERNAL), ) + ACTION_OPEN_EXISTING_TAB -> PendingAction.OpenExistingTab( + tabId = getString(KEY_EXISTING_TAB_ID) ?: return null, + ) else -> return null } return PendingModeSwitch(targetMode, action) @@ -90,5 +98,7 @@ private const val KEY_QUERY = "pendingModeSwitchQuery" private const val KEY_SOURCE_TAB_ID = "pendingModeSwitchSourceTabId" private const val KEY_SKIP_HOME = "pendingModeSwitchSkipHome" private const val KEY_IS_EXTERNAL = "pendingModeSwitchIsExternal" +private const val KEY_EXISTING_TAB_ID = "pendingModeSwitchExistingTabId" private const val ACTION_PROCESS_INTENT = "processIntent" private const val ACTION_OPEN_NEW_TAB = "openNewTab" +private const val ACTION_OPEN_EXISTING_TAB = "openExistingTab" diff --git a/app/src/test/java/com/duckduckgo/app/browser/PendingModeSwitchTest.kt b/app/src/test/java/com/duckduckgo/app/browser/PendingModeSwitchTest.kt index e4921f8295b4..a2dae49a400e 100644 --- a/app/src/test/java/com/duckduckgo/app/browser/PendingModeSwitchTest.kt +++ b/app/src/test/java/com/duckduckgo/app/browser/PendingModeSwitchTest.kt @@ -116,6 +116,28 @@ class PendingModeSwitchTest { assertNull(bundle.toPendingModeSwitch()) } + @Test + fun whenOpenExistingTabActionHasNoTabIdThenDecodesToNull() { + val bundle = PendingModeSwitch(BrowserMode.FIRE, PendingAction.OpenExistingTab("tab-123")) + .toBundle() + bundle.remove(KEY_EXISTING_TAB_ID) + + assertNull(bundle.toPendingModeSwitch()) + } + + @Test + fun whenOpenExistingTabRoundTrippedThroughBundleThenPreserved() { + val original = PendingModeSwitch( + targetMode = BrowserMode.FIRE, + action = PendingAction.OpenExistingTab("tab-123"), + ) + + val restored = original.toBundle().toPendingModeSwitch() + + assertEquals(BrowserMode.FIRE, restored?.targetMode) + assertEquals(PendingAction.OpenExistingTab("tab-123"), restored?.action) + } + private fun openNewTabBundle() = PendingModeSwitch( targetMode = BrowserMode.REGULAR, action = PendingAction.OpenNewTab(query = "q", sourceTabId = "t", skipHome = false, isExternal = false), @@ -131,5 +153,6 @@ class PendingModeSwitchTest { const val KEY_TARGET_MODE = "pendingModeSwitchTargetMode" const val KEY_ACTION = "pendingModeSwitchAction" const val KEY_INTENT = "pendingModeSwitchIntent" + const val KEY_EXISTING_TAB_ID = "pendingModeSwitchExistingTabId" } } From 52b75f675cabd19f52dd71bb4a7817dd12f0f7c1 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Jun 2026 17:55:18 +0200 Subject: [PATCH 04/10] Drive the escape hatch ViewModel from the resolver and expose the target tab mode Co-Authored-By: Claude Opus 4.8 (1M context) --- browser/browser-ui/build.gradle | 1 + .../hatch/NewTabReturnHatchViewModel.kt | 99 ++++++++++++------- .../hatch/NewTabReturnHatchViewModelTest.kt | 95 +++++++++++++++++- 3 files changed, 154 insertions(+), 41 deletions(-) diff --git a/browser/browser-ui/build.gradle b/browser/browser-ui/build.gradle index a228cffcb9ef..25929c609239 100644 --- a/browser/browser-ui/build.gradle +++ b/browser/browser-ui/build.gradle @@ -51,6 +51,7 @@ dependencies { implementation project(path: ':app-build-config-api') implementation project(':internal-features-api') implementation project(':browser-api') + implementation project(':browser-mode-api') implementation project(':navigation-api') implementation project(':duckchat-api') implementation project(':new-tab-page-api') diff --git a/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModel.kt b/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModel.kt index 032158c84b3b..9e729cea6421 100644 --- a/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModel.kt +++ b/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModel.kt @@ -26,18 +26,23 @@ import com.duckduckgo.app.browser.DuckDuckGoUrlDetector import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily -import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.common.utils.DispatcherProvider import com.duckduckgo.di.scopes.ViewScope import com.duckduckgo.duckchat.api.DuckChat +import com.duckduckgo.newtabpage.api.EscapeHatchTarget +import com.duckduckgo.newtabpage.api.EscapeHatchTargetResolver import com.duckduckgo.newtabpage.api.NtpAfterIdleManager +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -45,13 +50,16 @@ import kotlinx.coroutines.launch import javax.inject.Inject @SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle +@OptIn(ExperimentalCoroutinesApi::class) @ContributesViewModel(ViewScope::class) class NewTabReturnHatchViewModel @Inject constructor( private val tabRepository: TabRepository, + private val tabRepositoryProvider: BrowserModeDataProvider, private val dispatchers: DispatcherProvider, private val duckChat: DuckChat, private val duckDuckGoUrlDetector: DuckDuckGoUrlDetector, private val ntpAfterIdleManager: NtpAfterIdleManager, + private val escapeHatchTargetResolver: EscapeHatchTargetResolver, private val pixel: Pixel, ) : ViewModel(), DefaultLifecycleObserver { @@ -61,6 +69,7 @@ class NewTabReturnHatchViewModel @Inject constructor( val tabId: String = "", val currentTabId: String = "", val shouldShow: Boolean = false, + val mode: BrowserMode = BrowserMode.REGULAR, val isDuckChat: Boolean = false, val isSerp: Boolean = false, val tabs: Int = 0, @@ -77,60 +86,73 @@ class NewTabReturnHatchViewModel @Inject constructor( private val pendingClose = MutableStateFlow(false) - // The tab the hatch offers to return to, captured once when the app returns from idle. Driving + // The target the hatch offers to return to, captured once when the app returns from idle. Driving // the hatch from this snapshot (instead of the live last-accessed flow) keeps the displayed tab // stable: it never switches to another tab while up, and survives the close/undo toggle. - private val snapshotTab = MutableStateFlow(null) + private val snapshotTarget = MutableStateFlow(null) init { - // Capture the last-accessed tab once per idle-return; reset on a fresh return so the hatch + // Capture the target once per idle-return; reset on a fresh return so the hatch // can re-appear for the next one. viewModelScope.launch(dispatchers.io()) { ntpAfterIdleManager.isAfterIdleReturn.collect { afterIdle -> if (afterIdle) { - snapshotTab.value = tabRepository.getLastAccessedTab() + snapshotTarget.value = escapeHatchTargetResolver.resolve() pendingClose.value = false } else { - snapshotTab.value = null + snapshotTarget.value = null } } } } - // Driven by the captured [snapshotTab] (not the live last-accessed flow) so the displayed tab is - // stable across the close/undo toggle. flowTabs both supplies the live tabs count and gates - // visibility: the hatch hides as soon as the snapshot tab leaves the repository (burned, closed, - // or purged), so every live ViewModel instance stays in sync without per-instance burn tracking. - val viewState = combine( - snapshotTab, - pendingClose, - tabRepository.flowTabs, - duckChat.observeNativeInputFieldUserSettingEnabled(), - ) { tab, closed, tabs, nativeInputEnabled -> - if (tab != null && !closed && tabs.any { it.tabId == tab.tabId }) { - val url = tab.url.orEmpty() - ViewState( - tabTitle = tab.title.orEmpty(), - url = url, - tabId = tab.tabId, - currentTabId = tab.tabId, - shouldShow = true, - isDuckChat = url.isNotEmpty() && duckChat.isDuckChatUrl(Uri.parse(url)), - isSerp = url.isNotEmpty() && duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url), - tabs = tabs.size, - showTabsButton = nativeInputEnabled, - ) + // Driven by the captured [snapshotTarget] (not the live last-accessed flow) so the displayed tab + // is stable across the close/undo toggle. For a Regular target, flowTabs both supplies the live + // tabs count and gates visibility: the hatch hides as soon as the snapshot tab leaves the + // repository (burned, closed, or purged). For a Fire target, visibility tracks the fire repo's + // flowTabs, while the activity-mode repo supplies the tabs count shown in the tab button. + val viewState = snapshotTarget.flatMapLatest { target -> + if (target == null) { + combine(tabRepository.flowTabs, duckChat.observeNativeInputFieldUserSettingEnabled()) { tabs, nativeInputEnabled -> + ViewState(shouldShow = false, tabs = tabs.size, showTabsButton = nativeInputEnabled) + } } else { - ViewState( - shouldShow = false, - tabs = tabs.size, - showTabsButton = nativeInputEnabled, - ) + val isFireTarget = target.mode == BrowserMode.FIRE + combine( + pendingClose, + tabRepositoryProvider.forMode(target.mode).flowTabs, + tabRepository.flowTabs, + duckChat.observeNativeInputFieldUserSettingEnabled(), + ) { closed, targetTabs, activityTabs, nativeInputEnabled -> + val tab = targetTabs.firstOrNull { it.tabId == target.tabId } + if (!closed && tab != null) { + // For a Fire target the hatch is shown in Regular mode, so we deliberately do not + // read the Fire tab's title/url — nothing private leaks into the Normal UI. + val url = if (isFireTarget) "" else tab.url.orEmpty() + ViewState( + tabTitle = if (isFireTarget) "" else tab.title.orEmpty(), + url = url, + tabId = tab.tabId, + currentTabId = tab.tabId, + shouldShow = true, + mode = target.mode, + isDuckChat = url.isNotEmpty() && duckChat.isDuckChatUrl(Uri.parse(url)), + isSerp = url.isNotEmpty() && duckDuckGoUrlDetector.isDuckDuckGoQueryUrl(url), + tabs = activityTabs.size, + showTabsButton = nativeInputEnabled, + ) + } else { + ViewState(shouldShow = false, tabs = activityTabs.size, showTabsButton = nativeInputEnabled) + } + } } } .flowOn(dispatchers.io()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ViewState()) + private val targetRepository: TabRepository + get() = tabRepositoryProvider.forMode(snapshotTarget.value?.mode ?: BrowserMode.REGULAR) + fun onHatchPressed() { pixel.fire(NewTabReturnHatchPixelName.OPTION_SELECTED_RETURN_TAB, type = Count) pixel.fire(NewTabReturnHatchPixelName.OPTION_SELECTED_RETURN_TAB_DAILY, type = Daily()) @@ -141,10 +163,11 @@ class NewTabReturnHatchViewModel @Inject constructor( pixel.fire(NewTabReturnHatchPixelName.OPTION_SELECTED_CLOSE_TAB_DAILY, type = Daily()) val tabId = viewState.value.currentTabId if (tabId.isEmpty()) return + val repo = targetRepository // Mark the tab deletable now so it disappears from the tab list/switcher immediately // (recoverable via undo); the actual delete is committed when the snackbar is dismissed. viewModelScope.launch(dispatchers.io()) { - tabRepository.markDeletable(listOf(tabId)) + repo.markDeletable(listOf(tabId)) } pendingClose.value = true commandChannel.trySend(Command.ShowTabClosedSnackbar(tabId)) @@ -158,16 +181,18 @@ class NewTabReturnHatchViewModel @Inject constructor( fun onUndoCloseTab(tabId: String) { // Restore the tab that was marked deletable on close, and re-show the hatch with the same // snapshot (no recompute, so it doesn't jump to a different tab). + val repo = targetRepository viewModelScope.launch(dispatchers.io()) { - tabRepository.undoDeletable(listOf(tabId)) + repo.undoDeletable(listOf(tabId)) } pendingClose.value = false } fun onTabClosedSnackbarDismissed(tabId: String) { // The tab was already marked deletable on close; commit the deletion now. + val repo = targetRepository viewModelScope.launch(dispatchers.io()) { - tabRepository.purgeDeletableTabs() + repo.purgeDeletableTabs() } // pendingClose intentionally not reset: once the user commits to closing the hatch's tab, // the hatch should not reappear until a fresh idle-return. diff --git a/browser/browser-ui/src/test/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModelTest.kt b/browser/browser-ui/src/test/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModelTest.kt index a433f169a6ed..45a045e76aa8 100644 --- a/browser/browser-ui/src/test/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModelTest.kt +++ b/browser/browser-ui/src/test/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModelTest.kt @@ -25,8 +25,12 @@ import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Count import com.duckduckgo.app.statistics.pixels.Pixel.PixelType.Daily import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.model.TabRepository +import com.duckduckgo.browsermode.api.BrowserMode +import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.common.test.CoroutineTestRule import com.duckduckgo.duckchat.api.DuckChat +import com.duckduckgo.newtabpage.api.EscapeHatchTarget +import com.duckduckgo.newtabpage.api.EscapeHatchTargetResolver import com.duckduckgo.newtabpage.api.NtpAfterIdleManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceUntilIdle @@ -56,6 +60,13 @@ class NewTabReturnHatchViewModelTest { private val mockNtpAfterIdleManager: NtpAfterIdleManager = mock() private val mockPixel: Pixel = mock() private val tabsFlow = MutableStateFlow>(emptyList()) + private val mockResolver: EscapeHatchTargetResolver = mock() + private val fireTabsFlow = MutableStateFlow>(emptyList()) + private val mockFireTabRepository: TabRepository = mock() + private val tabRepositoryProvider = object : BrowserModeDataProvider { + override fun forMode(mode: BrowserMode): TabRepository = + if (mode == BrowserMode.FIRE) mockFireTabRepository else mockTabRepository + } // Starts false so each test controls the idle-return rising edge that captures the snapshot. private val afterIdleReturnFlow = MutableStateFlow(false) @@ -66,25 +77,28 @@ class NewTabReturnHatchViewModelTest { @Before fun setup() { whenever(mockTabRepository.flowTabs).thenReturn(tabsFlow) + whenever(mockFireTabRepository.flowTabs).thenReturn(fireTabsFlow) whenever(mockNtpAfterIdleManager.isAfterIdleReturn).thenReturn(afterIdleReturnFlow) whenever(mockDuckChat.observeNativeInputFieldUserSettingEnabled()).thenReturn(nativeInputEnabledFlow) testee = NewTabReturnHatchViewModel( tabRepository = mockTabRepository, + tabRepositoryProvider = tabRepositoryProvider, dispatchers = coroutinesTestRule.testDispatcherProvider, duckChat = mockDuckChat, duckDuckGoUrlDetector = mockDuckDuckGoUrlDetector, ntpAfterIdleManager = mockNtpAfterIdleManager, + escapeHatchTargetResolver = mockResolver, pixel = mockPixel, ) } // Simulates returning from idle: the last-accessed tab is present in the repository (so the - // hatch can show it), the one-time last-accessed read is stubbed, and the rising edge triggers + // hatch can show it), the one-time resolver read is stubbed, and the rising edge triggers // the snapshot capture. private suspend fun returnFromIdleWith(tab: TabEntity?) { tabsFlow.value = listOfNotNull(tab) - whenever(mockTabRepository.getLastAccessedTab()).thenReturn(tab) + whenever(mockResolver.resolve()).thenReturn(tab?.let { EscapeHatchTarget(it.tabId, BrowserMode.REGULAR) }) afterIdleReturnFlow.value = false afterIdleReturnFlow.value = true } @@ -133,7 +147,7 @@ class NewTabReturnHatchViewModelTest { assertEquals("tab1", expectMostRecentItem().tabId) // A new last-accessed tab without a fresh idle-return must NOT re-emit / switch tabs. - whenever(mockTabRepository.getLastAccessedTab()).thenReturn(tab2) + whenever(mockResolver.resolve()).thenReturn(EscapeHatchTarget(tab2.tabId, BrowserMode.REGULAR)) advanceUntilIdle() expectNoEvents() @@ -149,7 +163,10 @@ class NewTabReturnHatchViewModelTest { returnFromIdleWith(tab1) assertEquals("tab1", expectMostRecentItem().tabId) - returnFromIdleWith(tab2) + tabsFlow.value = listOf(tab2) + whenever(mockResolver.resolve()).thenReturn(EscapeHatchTarget(tab2.tabId, BrowserMode.REGULAR)) + afterIdleReturnFlow.value = false + afterIdleReturnFlow.value = true assertEquals("tab2", expectMostRecentItem().tabId) } } @@ -490,4 +507,74 @@ class NewTabReturnHatchViewModelTest { verify(mockNtpAfterIdleManager).onTabSwitcherSelected() } + + @Test + fun whenFireTargetThenViewStateModeIsFireAndExistenceTracksFireRepo() = runTest { + val fireTab = TabEntity(tabId = "f1", url = "https://secret.com", title = "Secret") + fireTabsFlow.value = listOf(fireTab) + whenever(mockResolver.resolve()).thenReturn(EscapeHatchTarget("f1", BrowserMode.FIRE)) + + testee.viewState.test { + afterIdleReturnFlow.value = false + afterIdleReturnFlow.value = true + + val state = expectMostRecentItem() + assertTrue(state.shouldShow) + assertEquals(BrowserMode.FIRE, state.mode) + assertEquals("f1", state.tabId) + } + } + + @Test + fun whenFireTargetLeavesFireRepoThenHatchHides() = runTest { + val fireTab = TabEntity(tabId = "f1", url = "https://secret.com", title = "Secret") + fireTabsFlow.value = listOf(fireTab) + whenever(mockResolver.resolve()).thenReturn(EscapeHatchTarget("f1", BrowserMode.FIRE)) + + testee.viewState.test { + afterIdleReturnFlow.value = false + afterIdleReturnFlow.value = true + assertTrue(expectMostRecentItem().shouldShow) + + fireTabsFlow.value = emptyList() + assertFalse(expectMostRecentItem().shouldShow) + } + } + + @Test + fun whenFireTargetThenTitleAndUrlAreNotReadFromFireTab() = runTest { + val fireTab = TabEntity(tabId = "f1", url = "https://secret.com", title = "Secret") + fireTabsFlow.value = listOf(fireTab) + whenever(mockResolver.resolve()).thenReturn(EscapeHatchTarget("f1", BrowserMode.FIRE)) + + testee.viewState.test { + afterIdleReturnFlow.value = false + afterIdleReturnFlow.value = true + + val state = expectMostRecentItem() + assertEquals("", state.tabTitle) + assertEquals("", state.url) + } + } + + @Test + fun whenFireTargetClosedThenMarksDeletableOnFireRepoNotRegularRepo() = runTest { + val fireTab = TabEntity(tabId = "f1", url = "https://secret.com", title = "Secret") + fireTabsFlow.value = listOf(fireTab) + whenever(mockResolver.resolve()).thenReturn(EscapeHatchTarget("f1", BrowserMode.FIRE)) + + testee.viewState.test { + afterIdleReturnFlow.value = false + afterIdleReturnFlow.value = true + assertTrue(expectMostRecentItem().shouldShow) + + testee.closeTab() + advanceUntilIdle() + // Consume the hide state emitted when pendingClose flips to true + assertFalse(expectMostRecentItem().shouldShow) + } + + verify(mockFireTabRepository).markDeletable(listOf("f1")) + verify(mockTabRepository, never()).markDeletable(listOf("f1")) + } } From 5a999d329ab8f27f8abbc1ee701bb52ab36969ef Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Jun 2026 18:04:16 +0200 Subject: [PATCH 05/10] Render the Last used Fire Tab treatment in the escape hatch view Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/newtab/hatch/NewTabReturnHatchView.kt | 19 ++++++++++++++----- .../src/main/res/values/donottranslate.xml | 1 + 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchView.kt b/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchView.kt index 1ba54a4182fd..fdc2c6fa7cc2 100644 --- a/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchView.kt +++ b/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchView.kt @@ -30,6 +30,7 @@ import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.browser.api.ui.BrowserScreens.TabSwitcherScreenNoParams import com.duckduckgo.browser.ui.R import com.duckduckgo.browser.ui.databinding.ViewNewTabHatchBinding +import com.duckduckgo.browsermode.api.BrowserMode import com.duckduckgo.common.ui.menu.PopupMenu import com.duckduckgo.common.ui.view.gone import com.duckduckgo.common.ui.view.show @@ -129,15 +130,23 @@ class NewTabReturnHatchView @JvmOverloads constructor( val tabId: String get() = viewModel.viewState.value.tabId + val targetMode: BrowserMode + get() = viewModel.viewState.value.mode + fun render(state: NewTabReturnHatchViewModel.ViewState) { faviconJob.cancel() if (state.shouldShow) { - binding.returnHatchSiteTitle.text = state.titleOrPlaceholder() - if (state.isDuckChat) { - binding.returnHatchFavicon.setImageResource(CommonR.drawable.ic_duckai) + if (state.mode == BrowserMode.FIRE) { + binding.returnHatchSiteTitle.text = context.getString(R.string.newTabReturnHatchFireTabTitle) + binding.returnHatchFavicon.setImageResource(CommonR.drawable.ic_fire_tab_placeholder_96) } else { - faviconJob += viewModel.viewModelScope.launch { - faviconManager.loadToViewFromLocalWithRetry(state.tabId, state.url, binding.returnHatchFavicon) + binding.returnHatchSiteTitle.text = state.titleOrPlaceholder() + if (state.isDuckChat) { + binding.returnHatchFavicon.setImageResource(CommonR.drawable.ic_duckai) + } else { + faviconJob += viewModel.viewModelScope.launch { + faviconManager.loadToViewFromLocalWithRetry(state.tabId, state.url, binding.returnHatchFavicon) + } } } diff --git a/browser/browser-ui/src/main/res/values/donottranslate.xml b/browser/browser-ui/src/main/res/values/donottranslate.xml index 65d7f6b64eb7..3807a8b72127 100644 --- a/browser/browser-ui/src/main/res/values/donottranslate.xml +++ b/browser/browser-ui/src/main/res/values/donottranslate.xml @@ -21,6 +21,7 @@ Tabs Return to… + Last used Fire Tab Duck.ai Duck.ai icon Return to Tab From 498062acd7183e192ce80e25aef852ae39052df0 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Jun 2026 18:11:28 +0200 Subject: [PATCH 06/10] Open the escape hatch target tab in its own browser mode on tap Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 9aed48324a7f..556d8721256d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -3801,7 +3801,7 @@ class BrowserTabFragment : override fun onHatchPressed() { hideKeyboard() ntpAfterIdleManager.onReturnToPageTapped() - browserActivity?.openExistingTab(newTabReturnHatchView.tabId) + browserActivity?.openExistingTabInMode(newTabReturnHatchView.targetMode, newTabReturnHatchView.tabId) } override fun onHatchRendered(visible: Boolean) { From d548babd188e9e0183deca066a9ada5f2a5f1df5 Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Jun 2026 20:25:56 +0200 Subject: [PATCH 07/10] Capture the escape hatch close-target mode so undo and commit route to the right tab database Co-Authored-By: Claude Opus 4.8 (1M context) --- .../hatch/NewTabReturnHatchViewModel.kt | 7 ++++- .../hatch/NewTabReturnHatchViewModelTest.kt | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModel.kt b/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModel.kt index 9e729cea6421..5b6451f9fe95 100644 --- a/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModel.kt +++ b/browser/browser-ui/src/main/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModel.kt @@ -91,6 +91,10 @@ class NewTabReturnHatchViewModel @Inject constructor( // stable: it never switches to another tab while up, and survives the close/undo toggle. private val snapshotTarget = MutableStateFlow(null) + // The mode of the tab being closed, captured when close begins so undo/commit route to the same + // repo even if the active snapshot changes (e.g. a fresh idle-return) while the snackbar is up. + private var pendingCloseMode: BrowserMode = BrowserMode.REGULAR + init { // Capture the target once per idle-return; reset on a fresh return so the hatch // can re-appear for the next one. @@ -151,7 +155,7 @@ class NewTabReturnHatchViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), ViewState()) private val targetRepository: TabRepository - get() = tabRepositoryProvider.forMode(snapshotTarget.value?.mode ?: BrowserMode.REGULAR) + get() = tabRepositoryProvider.forMode(pendingCloseMode) fun onHatchPressed() { pixel.fire(NewTabReturnHatchPixelName.OPTION_SELECTED_RETURN_TAB, type = Count) @@ -163,6 +167,7 @@ class NewTabReturnHatchViewModel @Inject constructor( pixel.fire(NewTabReturnHatchPixelName.OPTION_SELECTED_CLOSE_TAB_DAILY, type = Daily()) val tabId = viewState.value.currentTabId if (tabId.isEmpty()) return + pendingCloseMode = snapshotTarget.value?.mode ?: BrowserMode.REGULAR val repo = targetRepository // Mark the tab deletable now so it disappears from the tab list/switcher immediately // (recoverable via undo); the actual delete is committed when the snackbar is dismissed. diff --git a/browser/browser-ui/src/test/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModelTest.kt b/browser/browser-ui/src/test/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModelTest.kt index 45a045e76aa8..d87c44a5a8b2 100644 --- a/browser/browser-ui/src/test/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModelTest.kt +++ b/browser/browser-ui/src/test/java/com/duckduckgo/browser/ui/newtab/hatch/NewTabReturnHatchViewModelTest.kt @@ -577,4 +577,32 @@ class NewTabReturnHatchViewModelTest { verify(mockFireTabRepository).markDeletable(listOf("f1")) verify(mockTabRepository, never()).markDeletable(listOf("f1")) } + + @Test + fun whenFireTargetClosedThenSnapshotClearedBeforeCommitThenPurgesFireRepoNotRegular() = runTest { + val fireTab = TabEntity(tabId = "f1", url = "https://secret.com", title = "Secret") + fireTabsFlow.value = listOf(fireTab) + whenever(mockResolver.resolve()).thenReturn(EscapeHatchTarget("f1", BrowserMode.FIRE)) + + testee.viewState.test { + afterIdleReturnFlow.value = false + afterIdleReturnFlow.value = true + assertTrue(expectMostRecentItem().shouldShow) + + testee.closeTab() + advanceUntilIdle() + assertFalse(expectMostRecentItem().shouldShow) + + afterIdleReturnFlow.value = false // snapshot clears before the user commits the close + advanceUntilIdle() + + testee.onTabClosedSnackbarDismissed("f1") + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + + verify(mockFireTabRepository).markDeletable(listOf("f1")) + verify(mockFireTabRepository).purgeDeletableTabs() + verify(mockTabRepository, never()).purgeDeletableTabs() + } } From 65118c3991eb628d91c22941c54dbde8c2b5bfaa Mon Sep 17 00:00:00 2001 From: 0nko Date: Wed, 17 Jun 2026 21:28:51 +0200 Subject: [PATCH 08/10] Skip app-launch handling on a mode-switch recreate so the hatch returns to its target tab Co-Authored-By: Claude Opus 4.8 (1M context) --- .../duckduckgo/app/browser/BrowserActivity.kt | 4 ++ .../browser/state/ModeSwitchRecreateSignal.kt | 41 +++++++++++++++++ .../showonapplaunch/FirstScreenHandler.kt | 7 +++ .../state/ModeSwitchRecreateSignalTest.kt | 46 +++++++++++++++++++ .../FirstScreenHandlerImplTest.kt | 21 +++++++++ 5 files changed, 119 insertions(+) create mode 100644 app/src/main/java/com/duckduckgo/app/browser/state/ModeSwitchRecreateSignal.kt create mode 100644 app/src/test/java/com/duckduckgo/app/browser/state/ModeSwitchRecreateSignalTest.kt 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 4eb341c35899..4116b610b51d 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -70,6 +70,7 @@ import com.duckduckgo.app.browser.mode.BrowserLaunchSource import com.duckduckgo.app.browser.omnibar.OmnibarEntryConverter import com.duckduckgo.app.browser.omnibar.OmnibarType import com.duckduckgo.app.browser.shortcut.ShortcutBuilder +import com.duckduckgo.app.browser.state.ModeSwitchRecreateSignal import com.duckduckgo.app.browser.tabs.TabManager import com.duckduckgo.app.browser.tabs.TabManager.TabModel import com.duckduckgo.app.browser.tabs.adapter.TabPagerAdapter @@ -207,6 +208,8 @@ open class BrowserActivity : DuckDuckGoActivity() { @Inject lateinit var dispatcherProvider: DispatcherProvider + @Inject lateinit var modeSwitchRecreateSignal: ModeSwitchRecreateSignal + @Inject lateinit var externalIntentProcessingState: ExternalIntentProcessingState @@ -1342,6 +1345,7 @@ open class BrowserActivity : DuckDuckGoActivity() { .flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED) .collect { mode -> if (mode != currentBrowserMode) { + modeSwitchRecreateSignal.markPending() recreate() } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/state/ModeSwitchRecreateSignal.kt b/app/src/main/java/com/duckduckgo/app/browser/state/ModeSwitchRecreateSignal.kt new file mode 100644 index 000000000000..b899395c91dd --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/browser/state/ModeSwitchRecreateSignal.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.state + +import com.duckduckgo.di.scopes.AppScope +import dagger.SingleInstanceIn +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject + +/** + * One-shot signal that a programmatic browser-mode switch is about to recreate the browser activity. + * + * A `recreate()` fires onClose+onOpen as if the app reopened; launch-time handling consumes this to + * tell that recreate apart from a real app launch or resume, so it doesn't re-run launch behaviour. + */ +@SingleInstanceIn(AppScope::class) +class ModeSwitchRecreateSignal @Inject constructor() { + private val pending = AtomicBoolean(false) + + /** Mark that the next browser-activity (re)start is a mode-switch recreate, not a launch/resume. */ + fun markPending() { + pending.set(true) + } + + /** Returns true once if a mode-switch recreate is pending, then clears the flag. */ + fun consumePending(): Boolean = pending.getAndSet(false) +} diff --git a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/FirstScreenHandler.kt b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/FirstScreenHandler.kt index f61d3468a144..303b84bddb89 100644 --- a/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/FirstScreenHandler.kt +++ b/app/src/main/java/com/duckduckgo/app/generalsettings/showonapplaunch/FirstScreenHandler.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.generalsettings.showonapplaunch import com.duckduckgo.app.browser.autofill.SystemAutofillEngagement +import com.duckduckgo.app.browser.state.ModeSwitchRecreateSignal import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage import com.duckduckgo.app.generalsettings.showonapplaunch.store.ShowOnAppLaunchOptionDataStore @@ -60,6 +61,7 @@ class FirstScreenHandlerImpl @Inject constructor( private val systemAutofillEngagement: SystemAutofillEngagement, private val customTabDetector: CustomTabDetector, private val browserModeStateHolder: BrowserModeStateHolder, + private val modeSwitchRecreateSignal: ModeSwitchRecreateSignal, @AppCoroutineScope private val appCoroutineScope: CoroutineScope, ) : BrowserLifecycleObserver { @@ -67,6 +69,11 @@ class FirstScreenHandlerImpl @Inject constructor( get() = tabRepositoryProvider.forMode(browserModeStateHolder.currentMode.value) override fun onOpen(isFreshLaunch: Boolean) { + // A programmatic mode switch recreates BrowserActivity, which fires onClose+onOpen as if the + // app reopened. Skip launch handling for that recreate so it doesn't add a spurious NTP that + // clobbers the action carried across the switch (e.g. the escape hatch's OpenExistingTab). + if (modeSwitchRecreateSignal.consumePending()) return + // Notify the NtpAfterIdleManager synchronously on a fresh launch when the currently // selected tab is already an NTP: BrowserViewModel's flowSelectedTab subscription will // fire onNtpShown immediately on activity recreation, and the async handler path below diff --git a/app/src/test/java/com/duckduckgo/app/browser/state/ModeSwitchRecreateSignalTest.kt b/app/src/test/java/com/duckduckgo/app/browser/state/ModeSwitchRecreateSignalTest.kt new file mode 100644 index 000000000000..e87ca194829b --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/browser/state/ModeSwitchRecreateSignalTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.browser.state + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ModeSwitchRecreateSignalTest { + + private val testee = ModeSwitchRecreateSignal() + + @Test + fun whenNoPendingMarkThenConsumePendingReturnsFalse() { + assertFalse(testee.consumePending()) + } + + @Test + fun whenMarkPendingThenConsumePendingReturnsTrue() { + testee.markPending() + + assertTrue(testee.consumePending()) + } + + @Test + fun whenMarkPendingThenConsumePendingReturnsTrueOnlyOnce() { + testee.markPending() + + assertTrue(testee.consumePending()) + assertFalse(testee.consumePending()) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/FirstScreenHandlerImplTest.kt b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/FirstScreenHandlerImplTest.kt index e0e2b6adb882..9f7cef60617e 100644 --- a/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/FirstScreenHandlerImplTest.kt +++ b/app/src/test/java/com/duckduckgo/app/generalsettings/showonapplaunch/FirstScreenHandlerImplTest.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.generalsettings.showonapplaunch import androidx.lifecycle.MutableLiveData import androidx.test.ext.junit.runners.AndroidJUnit4 import com.duckduckgo.app.browser.autofill.SystemAutofillEngagement +import com.duckduckgo.app.browser.state.ModeSwitchRecreateSignal import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.LastOpenedTab import com.duckduckgo.app.generalsettings.showonapplaunch.model.ShowOnAppLaunchOption.NewTabPage import com.duckduckgo.app.generalsettings.showonapplaunch.store.FakeShowOnAppLaunchOptionDataStore @@ -78,6 +79,7 @@ class FirstScreenHandlerImplTest { private val idleReturnToggle: Toggle = mock() private val showOnAppLaunchToggle: Toggle = mock() private val ntpAfterIdleManager: NtpAfterIdleManager = mock() + private val modeSwitchRecreateSignal = ModeSwitchRecreateSignal() private val testScope = coroutineTestRule.testScope private lateinit var testee: FirstScreenHandlerImpl @@ -108,6 +110,7 @@ class FirstScreenHandlerImplTest { ntpAfterIdleManager = ntpAfterIdleManager, systemAutofillEngagement = systemAutofillEngagement, customTabDetector = customTabDetector, + modeSwitchRecreateSignal = modeSwitchRecreateSignal, dispatcherProvider = coroutineTestRule.testDispatcherProvider, appCoroutineScope = testScope, ) @@ -650,4 +653,22 @@ class FirstScreenHandlerImplTest { verify(showOnAppLaunchOptionHandler).handleAfterInactivityOption(wasIdle = true) } + + // --- Mode-switch recreate guard --- + + @Test + fun whenModeSwitchRecreateSignalPendingThenOnOpenSkipsLaunchHandling() = runTest { + whenever(idleReturnToggle.isEnabled()).thenReturn(true) + whenever(idleReturnToggle.getSettings()).thenReturn("""{"defaultIdleThresholdSeconds": 300}""") + val sixMinutesAgo = System.currentTimeMillis() - (6 * 60 * 1000) + whenever(settingsDataStore.lastSessionBackgroundTimestamp).thenReturn(sixMinutesAgo) + liveSelectedTab.value = TabEntity(tabId = "ntp", url = null) + + modeSwitchRecreateSignal.markPending() + testee.onOpen(isFreshLaunch = false) + testScope.testScheduler.advanceUntilIdle() + + verify(ntpAfterIdleManager, never()).onIdleReturnTriggered() + verifyNoInteractions(showOnAppLaunchOptionHandler) + } } From 771a1fdd322a8ac1d26169ddd9e6a0405cb23631 Mon Sep 17 00:00:00 2001 From: 0nko Date: Thu, 18 Jun 2026 10:08:28 +0200 Subject: [PATCH 09/10] Add feature flag gate --- .../impl/RealEscapeHatchTargetResolver.kt | 13 +++++---- .../impl/RealEscapeHatchTargetResolverTest.kt | 28 ++++++++++++++++++- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolver.kt b/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolver.kt index 8852f24e88cf..b9863e04c0cd 100644 --- a/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolver.kt +++ b/new-tab-page/new-tab-page-impl/src/main/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolver.kt @@ -20,6 +20,7 @@ import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.browsermode.api.BrowserMode import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.browsermode.api.BrowserModeStateHolder +import com.duckduckgo.browsermode.api.FireModeAvailability import com.duckduckgo.di.scopes.AppScope import com.duckduckgo.newtabpage.api.EscapeHatchTarget import com.duckduckgo.newtabpage.api.EscapeHatchTargetResolver @@ -33,6 +34,7 @@ import javax.inject.Inject class RealEscapeHatchTargetResolver @Inject constructor( private val browserModeStateHolder: BrowserModeStateHolder, private val tabRepositoryProvider: BrowserModeDataProvider, + private val fireModeAvailability: FireModeAvailability, ) : EscapeHatchTargetResolver { override suspend fun resolve(): EscapeHatchTarget? { @@ -40,11 +42,12 @@ class RealEscapeHatchTargetResolver @Inject constructor( // was set in Regular before the user switched to Fire. if (browserModeStateHolder.currentMode.value == BrowserMode.FIRE) return null - // In Regular mode (cold or hot) the hatch offers the globally most-recently-used tab: take - // each mode's most-recent tab (tagged with its owning mode) and keep whichever was accessed - // last. On a hot resume that is the just-used Regular tab; only on a cold start can a Fire - // tab win. REGULAR is listed first, so it wins ties. - val candidates = listOf(BrowserMode.REGULAR, BrowserMode.FIRE).mapNotNull { mode -> + val candidateModes = if (fireModeAvailability.isAvailable()) { + listOf(BrowserMode.REGULAR, BrowserMode.FIRE) + } else { + listOf(BrowserMode.REGULAR) + } + val candidates = candidateModes.mapNotNull { mode -> tabRepositoryProvider.forMode(mode).getLastAccessedTab()?.let { it to mode } } val (tab, mode) = candidates.maxByOrNull { (candidate, _) -> candidate.lastAccessTime ?: LocalDateTime.MIN } diff --git a/new-tab-page/new-tab-page-impl/src/test/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolverTest.kt b/new-tab-page/new-tab-page-impl/src/test/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolverTest.kt index 77841f695700..c697eea9c2f2 100644 --- a/new-tab-page/new-tab-page-impl/src/test/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolverTest.kt +++ b/new-tab-page/new-tab-page-impl/src/test/java/com/duckduckgo/newtabpage/impl/RealEscapeHatchTargetResolverTest.kt @@ -21,6 +21,7 @@ import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.browsermode.api.BrowserMode import com.duckduckgo.browsermode.api.BrowserModeDataProvider import com.duckduckgo.browsermode.api.BrowserModeStateHolder +import com.duckduckgo.browsermode.api.FireModeAvailability import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -28,6 +29,8 @@ import org.junit.Assert.assertNull import org.junit.Test import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.time.LocalDateTime @@ -37,13 +40,14 @@ class RealEscapeHatchTargetResolverTest { private val fireRepo: TabRepository = mock() private val modeFlow = MutableStateFlow(BrowserMode.REGULAR) private val stateHolder: BrowserModeStateHolder = mock { on { currentMode } doReturn modeFlow } + private val fireModeAvailability: FireModeAvailability = mock { on { isAvailable() } doReturn true } private val provider = object : BrowserModeDataProvider { override fun forMode(mode: BrowserMode): TabRepository = if (mode == BrowserMode.FIRE) fireRepo else regularRepo } - private val testee = RealEscapeHatchTargetResolver(stateHolder, provider) + private val testee = RealEscapeHatchTargetResolver(stateHolder, provider, fireModeAvailability) private fun tab(id: String, accessed: LocalDateTime?) = TabEntity(tabId = id, url = "https://$id.com", title = id, lastAccessTime = accessed) @@ -106,4 +110,26 @@ class RealEscapeHatchTargetResolverTest { assertNull(testee.resolve()) } + + @Test + fun whenFireModeUnavailableThenFireTabIgnoredEvenIfMoreRecent() = runTest { + whenever(fireModeAvailability.isAvailable()).thenReturn(false) + whenever(regularRepo.getLastAccessedTab()).thenReturn(tab("reg", LocalDateTime.of(2026, 6, 1, 9, 0))) + whenever(fireRepo.getLastAccessedTab()).thenReturn(tab("fire", LocalDateTime.of(2026, 6, 1, 10, 0))) + + val target = testee.resolve() + + assertEquals("reg", target?.tabId) + assertEquals(BrowserMode.REGULAR, target?.mode) + verify(fireRepo, never()).getLastAccessedTab() + } + + @Test + fun whenFireModeUnavailableAndOnlyFireTabThenReturnsNull() = runTest { + whenever(fireModeAvailability.isAvailable()).thenReturn(false) + whenever(regularRepo.getLastAccessedTab()).thenReturn(null) + whenever(fireRepo.getLastAccessedTab()).thenReturn(tab("fire", LocalDateTime.of(2026, 6, 1, 10, 0))) + + assertNull(testee.resolve()) + } } From 3f6348a7b21c85a5da69bb17c438b423133a721e Mon Sep 17 00:00:00 2001 From: 0nko Date: Thu, 18 Jun 2026 11:31:41 +0200 Subject: [PATCH 10/10] Add Fire mode support to input screen escape hatch --- .../java/com/duckduckgo/app/browser/BrowserTabFragment.kt | 5 ++++- .../duckchat/api/inputscreen/InputScreenActivityParams.kt | 3 +++ .../duckchat/impl/inputscreen/ui/InputScreenFragment.kt | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index 556d8721256d..e8d5c3098976 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -1060,7 +1060,10 @@ class BrowserTabFragment : InputScreenActivityResultCodes.SWITCH_TO_TAB_REQUESTED -> { data?.getStringExtra(InputScreenActivityResultParams.TAB_ID_PARAM)?.let { tabId -> - browserActivity?.openExistingTab(tabId) + val mode = data.getStringExtra(InputScreenActivityResultParams.TAB_MODE_PARAM) + ?.let { runCatching { BrowserMode.valueOf(it) }.getOrNull() } + ?: browserMode + browserActivity?.openExistingTabInMode(mode, tabId) } } diff --git a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/inputscreen/InputScreenActivityParams.kt b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/inputscreen/InputScreenActivityParams.kt index c08c6bb49f20..af6d9482251e 100644 --- a/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/inputscreen/InputScreenActivityParams.kt +++ b/duckchat/duckchat-api/src/main/java/com/duckduckgo/duckchat/api/inputscreen/InputScreenActivityParams.kt @@ -95,6 +95,9 @@ data object InputScreenActivityResultParams { /** Key for the target tab ID when result is [InputScreenActivityResultCodes.SWITCH_TO_TAB_REQUESTED] */ const val TAB_ID_PARAM = "tab_id" + /** Key for the target tab's [com.duckduckgo.browsermode.api.BrowserMode] name when result is [InputScreenActivityResultCodes.SWITCH_TO_TAB_REQUESTED] */ + const val TAB_MODE_PARAM = "tab_mode" + /** Key for any canceled draft content when result is [Activity.RESULT_CANCELED] */ const val CANCELED_DRAFT_PARAM = "draft" diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenFragment.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenFragment.kt index daf7b0e74983..8d55a8970f6f 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenFragment.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/inputscreen/ui/InputScreenFragment.kt @@ -634,7 +634,9 @@ class InputScreenFragment : DuckDuckGoFragment(R.layout.fragment_input_screen) { override fun onHatchPressed() { ntpAfterIdleManager.onReturnToPageTapped() val tabId = binding.inputScreenHatch.tabId - val data = Intent().putExtra(InputScreenActivityResultParams.TAB_ID_PARAM, tabId) + val data = Intent() + .putExtra(InputScreenActivityResultParams.TAB_ID_PARAM, tabId) + .putExtra(InputScreenActivityResultParams.TAB_MODE_PARAM, binding.inputScreenHatch.targetMode.name) requireActivity().setResult(InputScreenActivityResultCodes.SWITCH_TO_TAB_REQUESTED, data) exitInputScreen() }