Skip to content

Fire Mode: Tab data isolation#8591

Open
0nko wants to merge 27 commits into
feature/ondrej/fire-mode-webview-profilesfrom
feature/ondrej/fire-mode-fire-tabs
Open

Fire Mode: Tab data isolation#8591
0nko wants to merge 27 commits into
feature/ondrej/fire-mode-webview-profilesfrom
feature/ondrej/fire-mode-fire-tabs

Conversation

@0nko
Copy link
Copy Markdown
Member

@0nko 0nko commented May 18, 2026

Task/Issue URL: https://app.asana.com/1/137249556945/project/1207418217763355/task/1214821628980689?focus=true

Description

  1. Isolated tab storage per mode. Fire-mode tabs persist in their own Room database, separate from regular tabs. Other tab-scoped state stays shared and will be cleaned per-mode later by the data-clearing plugin.

  2. Mode-aware tab consumers. The tab switcher and autocomplete react to mode changes at runtime, reading from whichever mode is currently active. All behavior is gated behind the fireTabs feature flag — when disabled, the app behaves exactly as before.

  3. No public API or burn-flow changes. The TabRepository interface is untouched and the existing burn (clear-all-data) path is untouched. This lays the data foundation; Fire-tab UI lands in a separate workstream.

DI changes for fire-mode tab isolation

To support per-mode tab repositories without forcing every existing consumer to choose a side upfront, this PR introduces three coordinated bindings:

1. Unqualified TabRepository — ActivityScope only

Previously bindUnqualifiedTabRepository(@RegularMode impl) made every plain tabRepository: TabRepository injection silently resolve to the regular repo — a footgun once fire mode exists. The unqualified binding now lives in UnqualifiedTabRepositoryActivityModule and is contributed only to ActivityScope. AppScope consumers can no longer resolve unqualified TabRepository (the build fails with MissingBinding), forcing them to declare @RegularMode, @FireMode, both, BrowserModeDataProvider<TabRepository>, or AggregateTabRepository. Activity- and fragment-scoped consumers transparently get whichever repo matches the activity's current browser mode.

2. Activity-scoped BrowserMode

provideActivityBrowserMode is @SingleInstanceIn(ActivityScope::class) so it captures browserModeStateHolder.currentMode.value once at activity component creation. The unqualified repo binding and BrowserActivity itself both read from this frozen value, so the whole activity instance is locked to one mode for its lifetime. Mode changes trigger recreate(), which builds a fresh activity component that captures the new value cleanly — the two readers can never disagree mid-render.

3. AggregateTabRepository

New interface in browser-api and impl in :app (@ContributesBinding(AppScope)) exposing flowTabs: Flow<List<TabEntity>> that combines both repos. For consumers that span modes (voice session lifecycle, "user has interacted" CTA hints), this is cleaner than injecting both qualifiers separately.

Injection-point inventory

Consumer Choice Why
BrowserViewModel Current mode Frozen to the activity's mode; activity recreates on mode change
BrowserTabViewModel Current mode Per-tab VM follows the activity's mode
DefaultTabManager Current mode Manages the active mode's tabs
OmnibarLayoutViewModel Current mode Omnibar shows the activity's mode
BrowserNavigationBarViewModel Current mode Nav-bar chip is per-mode
GranularFireDialogViewModel Current mode Per-mode dialog; cross-mode handling deferred to data-clearing work
SingleTabFireDialogViewModel Current mode Same as above
InputScreenViewModel Current mode Duck-AI input screen uses current-mode repo
NewTabReturnHatchViewModel Current mode Hot-start case correct; cold-start cross-mode is future work
TabSwitcherViewModel BrowserModeDataProvider<TabRepository> The toggle lives in this screen; needs reactive resolution without recreate
AutoComplete BrowserModeDataProvider<TabRepository> Reactive flatMapLatest since instances can outlive a mode change
BrowserViewModel mode-change observer StateFlow from state holder Reactive observation drives recreate()
FirstScreenHandler BrowserModeDataProvider<TabRepository> + state holder App-scope singleton, lookup at call time
ExternalIntentProcessingState BrowserModeDataProvider<TabRepository> + flatMapLatest AppScope subscriber must re-subscribe on mode change
VoiceSessionStateManager AggregateTabRepository Listens to tab removal across both modes to end voice sessions
CtaViewModel AggregateTabRepository "User has any tabs" is mode-independent
ShowOnAppLaunchOptionHandler @RegularMode App-launch path is regular-only
TabStatsBucketing @RegularMode Long-term stats reflect persistent tabs only
TabsDbSanitizer @RegularMode + @FireMode Sanitizes both databases
DataClearing @RegularMode Cross-mode tab clearing deferred to data-clearing fire-mode work
PrivacyModule.clearDataActionClearPersonalDataAction @RegularMode Same as above; burn currently clears regular only

Steps to test this PR

Smoke testing the existing functionality with the FF off is sufficient for now.

  • Open the app and open a few tabs
  • Smoke test that everything works normally in the browser
  • Open the tab switcher
  • Verify the correct number of tabs is displayed
  • Create and close some tabs and check that it behaves as expected

Note

Medium Risk
Medium risk because it restructures tab storage/DI and makes several tab consumers mode-aware; mistakes could cause missing tabs, incorrect mode routing, or state restoration bugs (though fire-mode is feature-gated and regular-mode paths are largely preserved).

Overview
Isolates tabs by browser mode. Adds a new Room FireModeDatabase (fire_mode.db) with its own TabsDao, exported schema, and test coverage so fire-mode tabs persist separately from regular tabs.

Makes tab consumers mode-aware and safer to inject. Introduces @RegularMode/@FireMode qualifiers plus a BrowserModeDataProvider<TabRepository> and AggregateTabRepository to support per-mode lookup and cross-mode tab flows; moves the unqualified TabRepository/BrowserMode binding to ActivityScope (freezing mode per-activity instance) and updates key consumers (AutoComplete, TabSwitcherViewModel, ExternalIntentProcessingState, FirstScreenHandler, TabsDbSanitizer, pixels/stats/CTA/voice-session) to use the appropriate qualified/provider/aggregate repositories.

Fixes mode-switch recreation edge cases and adds dev tooling. BrowserActivity now injects an activity-scoped BrowserMode and skips saving/restoring tab pager state during mode-change recreate() to avoid restoring old-mode webviews; internal dev tabs screen gains controls to add/clear fire tabs (with per-tab deletion to avoid clearing shared singletons).

Reviewed by Cursor Bugbot for commit a162357. Bugbot is set up for automated code reviews on this repo. Configure here.

Copy link
Copy Markdown
Member Author

0nko commented May 18, 2026

Warning

This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack on Graphite.
Learn more

This stack of pull requests is managed by Graphite. Learn more about stacking.

Comment thread app/src/main/java/com/duckduckgo/app/tabs/db/TabsDbSanitizer.kt
Comment thread browser-mode/browser-mode-api/build.gradle Outdated
Comment thread app/src/main/java/com/duckduckgo/app/dispatchers/ExternalIntentProcessingState.kt Outdated
@0nko 0nko force-pushed the feature/ondrej/fire-mode-webview-profiles branch from f843786 to b7e580f Compare May 18, 2026 18:54
@0nko 0nko force-pushed the feature/ondrej/fire-mode-fire-tabs branch from 018e6ad to 53cfdf4 Compare May 18, 2026 18:54
@0nko 0nko mentioned this pull request May 18, 2026
33 tasks
return Room.databaseBuilder(context, FireModeDatabase::class.java, "fire_mode.db")
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.build()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fire mode database missing destructive migration fallback

Medium Severity

The FireModeDatabase Room builder lacks fallbackToDestructiveMigration(). Since this database stores ephemeral fire-mode tabs, any future schema change (bumping version beyond 1) without an explicit migration will crash the app with an IllegalStateException. Other ephemeral databases in the codebase (e.g., TabVisitedSitesModule) already use fallbackToDestructiveMigration(). For a fire-mode database whose data is meant to be discarded, destructive migration is the correct safety net.


Please tell me if this was useful or not with a 👍 or 👎.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5372d76. Configure here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems reasonable to include fallbackToDestructiveMigration here. What do you think, @0nko ?

@0nko 0nko force-pushed the feature/ondrej/fire-mode-fire-tabs branch from 27e1503 to a162357 Compare May 18, 2026 22:07
@0nko 0nko force-pushed the feature/ondrej/fire-mode-webview-profiles branch from b7e580f to 70343bf Compare May 18, 2026 22:07
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit a162357. Configure here.

duckChatContextualDataStore = duckChatContextualDataStore,
tabVisitedSitesRepository = tabVisitedSitesRepository,
nativeInputStatePublisher = nativeInputStatePublisher,
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared TabSwitcherDataStore causes cross-mode side effects

Medium Severity

Both the @RegularMode and @FireMode TabDataRepository instances receive the same tabSwitcherDataStore singleton. TabDataRepository.setIsUserNew() writes to this shared store — the fire repo instance could race with the regular one, and setTabLayoutType on either repo changes the preference for both. More critically, TabDataRepository.addDefaultTab() checks tabsDao.tabs().isNotEmpty() but the setIsUserNew logic in tabSwitcherDataStore evaluates a cross-mode UNKNOWN state. If the fire repo's setIsUserNew is called first, it would set the user state based on fire-mode context, preventing the regular repo from ever setting it correctly.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a162357. Configure here.

}

private val tabRepository: TabRepository
get() = tabRepositoryProvider.forMode(currentMode.value)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AutoComplete tabRepository getter inconsistent with reactive flow

Low Severity

The tabRepository getter resolves via currentMode.value at call time, but getAutocompleteSwitchToTabResults uses currentMode.flatMapLatest for reactive mode changes. In fireAutocompletePixel, tabRepository.liveTabs.value reads from whichever mode is current at that instant. If a mode switch occurs between the user seeing switch-to-tab suggestions (from the old mode's reactive flow still in-flight) and tapping one (triggering the pixel), the pixel's hasTabs param could reflect the wrong mode's tab count.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit a162357. Configure here.

Copy link
Copy Markdown
Member

@CDRussell CDRussell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smoke testing didn't reveal any problems; so that LGTM.

Can approve after addressing these:

  • I don't think there's an API proposal for AggregateTabRepository? If not, it should be covered somewhere.
  • I think the suggestion to use fallbackToDestructiveMigration is a good one to include.

// we don't store isExternal in the tab model, as it's only meant for the first time the tab is loaded.
private val externalLaunchTabIds = mutableSetOf<String>()

private var skipTabPagerStateSaveOnRecreate = false
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can you document why this addition and when it should be set one way or the other (i see there's a related comment elsewhere but it would be useful to explain its purpose here too)

return Room.databaseBuilder(context, FireModeDatabase::class.java, "fire_mode.db")
.setJournalMode(RoomDatabase.JournalMode.TRUNCATE)
.build()
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems reasonable to include fallbackToDestructiveMigration here. What do you think, @0nko ?

browserModeStateHolder.currentMode.value

/**
* AppScope-singleton consumers cannot reach this binding — they must inject the qualified
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice!

Comment on lines +31 to +32
TabEntity::class,
TabSelectionEntity::class,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: As per my warning in the tech design, two databases now use these same entities. it might be an issue if we change one of these entities and migrate one database but forget the other.

Can you add some docs to this class, the other database, and the entities themselves indicating they are all linked/shared (might just help prevent a future problem)

* For everything that operates on one mode at a time, inject the mode-qualified
* [TabRepository] directly.
*/
interface AggregateTabRepository {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API proposal?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants