Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,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
Expand Down Expand Up @@ -206,6 +207,8 @@ open class BrowserActivity : DuckDuckGoActivity() {

@Inject lateinit var dispatcherProvider: DispatcherProvider

@Inject lateinit var modeSwitchRecreateSignal: ModeSwitchRecreateSignal

@Inject
lateinit var externalIntentProcessingState: ExternalIntentProcessingState

Expand Down Expand Up @@ -1308,6 +1311,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
.flowWithLifecycle(lifecycle, Lifecycle.State.RESUMED)
.collect { mode ->
if (mode != currentBrowserMode) {
modeSwitchRecreateSignal.markPending()
recreate()
}
}
Expand Down Expand Up @@ -1754,6 +1758,7 @@ open class BrowserActivity : DuckDuckGoActivity() {
action.skipHome,
action.isExternal,
)
is PendingAction.OpenExistingTab -> openExistingTab(action.tabId)
}
}

Expand Down Expand Up @@ -1813,6 +1818,9 @@ open class BrowserActivity : DuckDuckGoActivity() {
}
}

fun openExistingTabInMode(mode: BrowserMode, tabId: String) =
switchModeThen(mode, PendingAction.OpenExistingTab(tabId))

fun onEditModeChanged(isInEditMode: Boolean) {
viewModel.onOmnibarEditModeChanged(isInEditMode)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1054,7 +1054,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)
}
}

Expand Down Expand Up @@ -3772,7 +3775,7 @@ class BrowserTabFragment :
override fun onHatchPressed() {
hideKeyboard()
ntpAfterIdleManager.onReturnToPageTapped()
browserActivity?.openExistingTab(newTabReturnHatchView.tabId)
browserActivity?.openExistingTabInMode(newTabReturnHatchView.targetMode, newTabReturnHatchView.tabId)
Comment thread
cursor[bot] marked this conversation as resolved.
}

override fun onHatchRendered(visible: Boolean) {
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/duckduckgo/app/browser/PendingModeSwitch.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
}
Expand All @@ -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)
Expand All @@ -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"
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,13 +61,19 @@ 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 {

private val tabRepository: TabRepository
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<BrowserInteractionsPlugin>,
private val tabRepositoryProvider: BrowserModeDataProvider<TabRepository>,
private val browserModeStateHolder: BrowserModeStateHolder,
) : ShowOnAppLaunchOptionHandler {

override suspend fun handleAfterInactivityOption(wasIdle: Boolean) {
Expand All @@ -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
Expand All @@ -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")
}
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -108,6 +110,7 @@ class FirstScreenHandlerImplTest {
ntpAfterIdleManager = ntpAfterIdleManager,
systemAutofillEngagement = systemAutofillEngagement,
customTabDetector = customTabDetector,
modeSwitchRecreateSignal = modeSwitchRecreateSignal,
dispatcherProvider = coroutineTestRule.testDispatcherProvider,
appCoroutineScope = testScope,
)
Expand Down Expand Up @@ -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)
}
}
Loading
Loading