Skip to content

Fire Mode: Tab data isolation#8591

Open
0nko wants to merge 31 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 31 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

API Proposal: https://app.asana.com/1/137249556945/project/1207418217763355/task/1215098408642711?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
Touches core tab storage, DI graph, and activity recreation on mode switch; burn still clears regular tabs only until follow-up work, and dual-database schema must stay in sync.

Overview
Introduces separate tab persistence for Fire mode via a new FireModeDatabase (fire_mode.db) alongside the existing app database, with dual @RegularMode / @FireMode TabDataRepository instances and @RegularMode / @FireMode TabsDao bindings.

DI and resolution are reworked so unqualified TabRepository and activity-scoped BrowserMode exist only in ActivityScope (frozen for the activity lifetime); app-scope code must use @RegularMode, @FireMode, BrowserModeDataProvider<TabRepository>, or new AggregateTabRepository (combined flowTabs across modes). RealTabRepositoryProvider maps mode → repo.

Runtime behavior (behind fireTabs / FireModeAvailability): tab switcher and autocomplete follow current mode with flatMapLatest; BrowserActivity recreates on mode change and skips saving tab pager state so old-mode WebViews are not restored; BrowserTabFragment no longer passes mode via arguments—it injects BrowserMode. Burn/clear-data paths still target @RegularMode only (noted TODOs). fireTabs remote default flips from internal to false.

Dev settings gain fire-tab add/clear helpers; tests and Room schema export cover the new DB. Shared TabEntity / TabSelectionEntity docs warn that schema changes need migrations in both databases.

Reviewed by Cursor Bugbot for commit 03d7181. 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
Comment thread app/src/main/java/com/duckduckgo/app/di/DatabaseModule.kt
@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
Comment thread app/src/main/java/com/duckduckgo/app/di/TabRepositoryModule.kt
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

There's a comment above observeBrowserModeChanges() but I added it here too.

Comment thread app/src/main/java/com/duckduckgo/app/di/DatabaseModule.kt
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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@0nko 0nko requested a review from CDRussell May 25, 2026 11:16
@0nko
Copy link
Copy Markdown
Member Author

0nko commented May 25, 2026

Thanks @CDRussell! I've addressed your comments, ready for another round.

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 singletons in fire TabDataRepository risk cross-mode data destruction

Medium Severity

The fire-mode TabDataRepository receives the same shared, non-mode-aware singletons (webViewPreviewPersister, faviconManager, adClickManager, webViewSessionStorage, duckChatContextualDataStore, tabVisitedSitesRepository, nativeInputStatePublisher) as the regular-mode repo. Calling deleteAll() on the fire repo would invoke .deleteAll() / .clearAll() on every one of these shared services, wiping data belonging to the regular mode too. The dev tools work around this with per-tab deletion, but deleteAll() is a public method on TabRepository and nothing prevents a future caller from invoking it on the fire instance.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cfd9771. Configure here.

@0nko 0nko force-pushed the feature/ondrej/fire-mode-webview-profiles branch from 6d5459e to ba1044a Compare May 25, 2026 11:54
@0nko 0nko force-pushed the feature/ondrej/fire-mode-fire-tabs branch from 9bc66d4 to 03d7181 Compare May 25, 2026 11:54
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 1 potential issue.

There are 2 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 03d7181. Configure here.

)
}
}
}
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.

addFireTabs missing IO dispatcher for database work

Low Severity

addFireTabs launches on the default (Main) dispatcher via viewModelScope.launch without specifying dispatcher.io(), unlike the sibling addTabs which correctly uses viewModelScope.launch(dispatcher.io()). Since fireTabDataRepository.add() performs database operations, this runs Room DB work on the main thread, which can cause ANRs or crashes if Room strict mode is enabled.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 03d7181. Configure here.

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