Skip to content

Commit 0f9da8a

Browse files
0nkocursoragent
andauthored
Fire Mode: WebView profile management (#8575)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1207418217763355/task/1214780084779090?focus=true ### Description Isolates WebView state between Regular and Fire browser modes via AndroidX webkit's `MULTI_PROFILE` API. Each newly-created `WebView` is bound to its mode-specific `androidx.webkit.Profile` so cookies, LocalStorage, IndexedDB, and other origin-keyed storage are partitioned per mode. No-op on devices without MultiProfile support. Pieces introduced: - **`WebViewModeInitializer`** (`browser-mode-api`) — binds a freshly-created `WebView` to the profile for a given `BrowserMode`. `BrowserTabFragment` injects it and calls `bind(it, browserMode)` inside `configureWebView()` immediately after inflating the `DuckDuckGoWebView`, before any settings or clients are applied. - **`WebStorageProvider`** (`BrowserModeDataProvider<WebStorage>`) — resolves the per-mode `WebStorage` via `ProfileStore`, falling back to the shared default `WebStorage` when MultiProfile is unavailable. - **`BrowserMode.profileName`** extension — stable mapping from mode to profile name (`REGULAR` → default profile, `FIRE` → `"Fire"`). - **`FireModeAvailability`** is now synchronous (`fun isAvailable()`), uses `WebViewFeature.MULTI_PROFILE` directly instead of `WebViewCapabilityChecker`, warms its cache on main-process `onCreate` via `MainProcessLifecycleObserver`, and freezes the first computed value so later flag flips don't change the answer mid-session. API proposal: [WebViewModeInitializer interface](https://app.asana.com/1/137249556945/project/1207418217763355/task/1214944190409737?focus=true) ### Steps to test this PR - [x] Smoke test the app and make sure data-clearing works as before. There should be no change to any user-visible functionality <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes WebView creation and session storage partitioning at tab open; Fire tabs can be closed if profile binding fails, but Regular mode and devices without MultiProfile should behave as before when binding is skipped or falls back. > > **Overview** > Introduces **WebView multi-profile** wiring so Regular and Fire tabs use separate AndroidX WebView profiles (cookies, LocalStorage, IndexedDB, etc.), with no binding when `MULTI_PROFILE` is unsupported. > > **`WebViewModeInitializer`** binds each new `WebView` to the profile for the tab’s `BrowserMode` (via **`BrowserMode.profileName`**) before any other WebView setup. **`BrowserTabFragment`** calls it right after inflation; if binding fails in a non-Regular mode, it logs, **closes the tab**, and tears down the WebView. > > **`WebStorageProvider`** implements **`BrowserModeDataProvider<WebStorage>`**, returning profile-scoped storage when Fire/multi-profile is available, otherwise the shared default. > > **`FireModeAvailability`** is now synchronous, checks **`WebViewFeature.MULTI_PROFILE`** directly (replacing **`WebViewCapabilityChecker`**), warms availability on main-process **`onCreate`**, and **freezes** the first computed result for the session. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 513ec83. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent f3c9eb8 commit 0f9da8a

10 files changed

Lines changed: 412 additions & 45 deletions

File tree

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ import com.duckduckgo.browser.ui.browsermenu.BrowserMenuBottomSheet
273273
import com.duckduckgo.browser.ui.browsermenu.VpnMenuState
274274
import com.duckduckgo.browser.ui.newtab.hatch.NewTabReturnHatchView
275275
import com.duckduckgo.browsermode.api.BrowserMode
276+
import com.duckduckgo.browsermode.api.WebViewModeInitializer
276277
import com.duckduckgo.common.ui.DuckDuckGoActivity
277278
import com.duckduckgo.common.ui.DuckDuckGoFragment
278279
import com.duckduckgo.common.ui.store.BrowserAppTheme
@@ -441,6 +442,9 @@ class BrowserTabFragment :
441442
@Inject
442443
lateinit var webChromeClient: BrowserChromeClient
443444

445+
@Inject
446+
lateinit var webViewModeInitializer: WebViewModeInitializer
447+
444448
@Inject
445449
lateinit var viewModelFactory: FragmentViewModelFactory
446450

@@ -3991,6 +3995,19 @@ class BrowserTabFragment :
39913995
).findViewById<DuckDuckGoWebView>(R.id.browserWebView)
39923996

39933997
webView?.let {
3998+
val bindResult = webViewModeInitializer.bind(it, browserMode)
3999+
if (bindResult.isFailure) {
4000+
if (browserMode != BrowserMode.REGULAR) {
4001+
bindResult.exceptionOrNull()?.message?.let {
4002+
message ->
4003+
logcat(ERROR) { message }
4004+
}
4005+
this.closeCurrentTab()
4006+
destroyWebView()
4007+
return
4008+
}
4009+
}
4010+
39944011
it.webViewClient = webViewClient
39954012
it.webChromeClient = webChromeClient
39964013
it.clearSslPreferences()
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.browser.mode
18+
19+
import android.annotation.SuppressLint
20+
import android.webkit.WebStorage
21+
import androidx.annotation.UiThread
22+
import androidx.webkit.ProfileStore
23+
import com.duckduckgo.browsermode.api.BrowserMode
24+
import com.duckduckgo.browsermode.api.BrowserModeDataProvider
25+
import com.duckduckgo.browsermode.api.FireModeAvailability
26+
import com.duckduckgo.browsermode.api.profileName
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.squareup.anvil.annotations.ContributesTo
29+
import dagger.Binds
30+
import dagger.Module
31+
import dagger.SingleInstanceIn
32+
import javax.inject.Inject
33+
34+
/**
35+
* Resolves a per-mode [WebStorage]. Callers must invoke [forMode] on the main thread when
36+
* `MULTI_PROFILE` is supported on the device — `ProfileStore` is tied to the WebView thread.
37+
* Falls back to the default shared [WebStorage] when MultiProfile is unsupported.
38+
*/
39+
@SuppressLint("RequiresFeature")
40+
@SingleInstanceIn(AppScope::class)
41+
class WebStorageProvider @Inject constructor(
42+
private val fireModeAvailability: FireModeAvailability,
43+
) : BrowserModeDataProvider<WebStorage> {
44+
45+
@UiThread
46+
override fun forMode(mode: BrowserMode): WebStorage {
47+
if (!fireModeAvailability.isAvailable()) return WebStorage.getInstance()
48+
return ProfileStore.getInstance().getOrCreateProfile(mode.profileName).webStorage
49+
}
50+
}
51+
52+
@ContributesTo(AppScope::class)
53+
@Module
54+
abstract class WebStorageProviderModule {
55+
@Binds
56+
abstract fun bindWebStorageProvider(impl: WebStorageProvider): BrowserModeDataProvider<WebStorage>
57+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.app.browser.mode
18+
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import com.duckduckgo.browsermode.api.BrowserMode
21+
import com.duckduckgo.browsermode.api.FireModeAvailability
22+
import org.junit.Assert.assertNotNull
23+
import org.junit.Test
24+
import org.junit.runner.RunWith
25+
import org.mockito.kotlin.mock
26+
import org.mockito.kotlin.whenever
27+
28+
@RunWith(AndroidJUnit4::class)
29+
class WebStorageProviderTest {
30+
31+
private val fireModeAvailability: FireModeAvailability = mock()
32+
private val testee = WebStorageProvider(fireModeAvailability)
33+
34+
@Test
35+
fun `forMode returns default WebStorage when multi-profile is unavailable`() {
36+
whenever(fireModeAvailability.isAvailable()).thenReturn(false)
37+
38+
assertNotNull(testee.forMode(BrowserMode.REGULAR))
39+
assertNotNull(testee.forMode(BrowserMode.FIRE))
40+
}
41+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.browsermode.api
18+
19+
import androidx.webkit.Profile
20+
21+
/**
22+
* Stable mapping from a [BrowserMode] to its WebView profile name.
23+
*
24+
* [REGULAR][BrowserMode.REGULAR] shares the default WebView profile;
25+
* [FIRE][BrowserMode.FIRE] gets its own isolated profile.
26+
*/
27+
val BrowserMode.profileName: String
28+
get() = when (this) {
29+
BrowserMode.REGULAR -> Profile.DEFAULT_PROFILE_NAME
30+
BrowserMode.FIRE -> "Fire"
31+
}

browser-mode/browser-mode-api/src/main/java/com/duckduckgo/browsermode/api/FireModeAvailability.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@ package com.duckduckgo.browsermode.api
2525
*/
2626
interface FireModeAvailability {
2727
/** Returns true if the prerequisites are satisfied, false otherwise **/
28-
suspend fun isAvailable(): Boolean
28+
fun isAvailable(): Boolean
2929
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.browsermode.api
18+
19+
import android.webkit.WebView
20+
21+
/**
22+
* Binds a [WebView] to the WebView profile associated with a [BrowserMode], isolating cookies
23+
* and origin-keyed storage (LocalStorage, IndexedDB) between modes.
24+
*
25+
* Must be called once per [WebView] before any other WebView API call, including `loadUrl`. The
26+
* binding is permanent for the lifetime of the [WebView] — it cannot be changed once the
27+
* [WebView] has been used.
28+
*
29+
* Fails on devices that do not support the `MULTI_PROFILE` WebView feature, regardless of the
30+
* requested [BrowserMode].
31+
*/
32+
interface WebViewModeInitializer {
33+
34+
/**
35+
* Must be called on the main thread.
36+
*
37+
* @param webView a freshly-created [WebView] that has not yet been used.
38+
* @param mode the [BrowserMode] whose profile should back this [WebView].
39+
* @return [Result.success] when the profile is bound to the [WebView]; [Result.failure] when
40+
* the `MULTI_PROFILE` WebView feature is unsupported on this device, or when the underlying
41+
* profile binding call throws.
42+
*/
43+
fun bind(webView: WebView, mode: BrowserMode): Result<Unit>
44+
}

browser-mode/browser-mode-impl/src/main/java/com/duckduckgo/browsermode/impl/RealFireModeAvailability.kt

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,30 +16,43 @@
1616

1717
package com.duckduckgo.browsermode.impl
1818

19-
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker
20-
import com.duckduckgo.app.browser.api.WebViewCapabilityChecker.WebViewCapability.MultiProfile
19+
import androidx.lifecycle.LifecycleOwner
20+
import androidx.webkit.WebViewFeature
21+
import com.duckduckgo.app.di.AppCoroutineScope
22+
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
2123
import com.duckduckgo.browsermode.api.FireModeAvailability
2224
import com.duckduckgo.common.utils.DispatcherProvider
2325
import com.duckduckgo.di.scopes.AppScope
2426
import com.squareup.anvil.annotations.ContributesBinding
27+
import com.squareup.anvil.annotations.ContributesMultibinding
2528
import dagger.SingleInstanceIn
26-
import kotlinx.coroutines.withContext
29+
import kotlinx.coroutines.CoroutineScope
30+
import kotlinx.coroutines.launch
2731
import javax.inject.Inject
2832

2933
@SingleInstanceIn(AppScope::class)
30-
@ContributesBinding(AppScope::class)
34+
@ContributesBinding(AppScope::class, boundType = FireModeAvailability::class)
35+
@ContributesMultibinding(AppScope::class, boundType = MainProcessLifecycleObserver::class)
3136
class RealFireModeAvailability @Inject constructor(
3237
private val fireModeFeature: FireModeFeature,
33-
private val webViewCapabilityChecker: WebViewCapabilityChecker,
3438
private val dispatchers: DispatcherProvider,
35-
) : FireModeAvailability {
39+
@param:AppCoroutineScope private val appScope: CoroutineScope,
40+
) : FireModeAvailability, MainProcessLifecycleObserver {
41+
3642
@Volatile
37-
private var multiProfileSupported: Boolean? = null
43+
private var cachedAvailability: Boolean? = null
44+
45+
override fun onCreate(owner: LifecycleOwner) {
46+
appScope.launch(dispatchers.io()) { computeAndCache() }
47+
}
48+
49+
override fun isAvailable(): Boolean = cachedAvailability ?: computeAndCache()
3850

39-
override suspend fun isAvailable(): Boolean = withContext(dispatchers.io()) {
40-
if (!fireModeFeature.fireTabs().isEnabled()) return@withContext false
41-
multiProfileSupported ?: webViewCapabilityChecker.isSupported(MultiProfile).also {
42-
multiProfileSupported = it
43-
}
51+
private fun computeAndCache(): Boolean {
52+
cachedAvailability?.let { return it }
53+
val value = WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE) &&
54+
fireModeFeature.fireTabs().isEnabled()
55+
cachedAvailability = value
56+
return value
4457
}
4558
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (c) 2026 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.browsermode.impl
18+
19+
import android.annotation.SuppressLint
20+
import android.webkit.WebView
21+
import androidx.webkit.ProfileStore
22+
import androidx.webkit.WebViewCompat
23+
import androidx.webkit.WebViewFeature
24+
import com.duckduckgo.browsermode.api.BrowserMode
25+
import com.duckduckgo.browsermode.api.WebViewModeInitializer
26+
import com.duckduckgo.browsermode.api.profileName
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.squareup.anvil.annotations.ContributesBinding
29+
import dagger.SingleInstanceIn
30+
import javax.inject.Inject
31+
32+
@SingleInstanceIn(AppScope::class)
33+
@ContributesBinding(AppScope::class)
34+
class RealWebViewModeInitializer @Inject constructor(
35+
private val webViewProfileBinder: WebViewProfileBinder,
36+
) : WebViewModeInitializer {
37+
override fun bind(webView: WebView, mode: BrowserMode): Result<Unit> {
38+
if (!WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) {
39+
return Result.failure(
40+
IllegalStateException(
41+
"Attempting to bind a WebView profile to " +
42+
"{${mode.name}} mode when MULTI_PROFILE feature is not available.",
43+
),
44+
)
45+
}
46+
return runCatching {
47+
webViewProfileBinder.bind(webView, mode.profileName)
48+
}
49+
}
50+
}
51+
52+
interface WebViewProfileBinder {
53+
fun bind(webView: WebView, profileName: String)
54+
}
55+
56+
@ContributesBinding(AppScope::class)
57+
@SuppressLint("RequiresFeature")
58+
class AndroidXWebViewProfileBinder @Inject constructor() : WebViewProfileBinder {
59+
override fun bind(webView: WebView, profileName: String) {
60+
ProfileStore.getInstance().getOrCreateProfile(profileName)
61+
WebViewCompat.setProfile(webView, profileName)
62+
}
63+
}

0 commit comments

Comments
 (0)