From 43aebcca638902e2ca4c295e3aa3957f80d6cb70 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Jun 2026 10:49:44 -0400 Subject: [PATCH] fix(embed): make embedded + full-screen WebViews follow the app theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embedded in-app browser and napplet/nSite surfaces rendered web content in the device theme, ignoring the app's DARK/LIGHT preference — a site with dark support stayed light when the app was dark (and vice versa). The full-screen activities had the same latent gap (they followed the device, not the app). Root cause: WebView's dark decision (prefers-color-scheme via algorithmic darkening) reads the context's THEME (?android:attr/isLightTheme), not just the Configuration uiMode. The off-window SurfaceControlViewHost surface context carries neither the host window's theme nor its night mode, so the renderer came up light. The old applyNightMode() used UiModeManager.setNightMode — a permission-gated no-op — so the theme never reached the WebView at all. Fix: build every embed/host WebView from nightThemedContext() — a ContextThemeWrapper over a forced-night/day Configuration with a DayNight theme, so the theme's isLightTheme resolves from the app's resolved theme. Shared in EmbedWebViewTheme.kt; used by NappletBrowserService, NappletHostService, NappletBrowserActivity, and NappletHostActivity. Removed the dead applyNightMode no-op from all four. (Verified on device with a throwaway SurfaceControlViewHost repro: config-only context does NOT work; setForceDark is a no-op at targetSdk 37; setApplicationNightMode does nothing; the DayNight ContextThemeWrapper is what flips the renderer, even across the cross-process embedded surface.) Device-verified: all four surfaces follow the app theme even when it differs from the device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/napplethost/EmbedWebViewTheme.kt | 59 +++++++++++++++++++ .../napplethost/NappletBrowserActivity.kt | 13 +--- .../napplethost/NappletBrowserService.kt | 11 +--- .../napplethost/NappletHostActivity.kt | 14 +---- .../napplethost/NappletHostService.kt | 11 +--- 5 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/EmbedWebViewTheme.kt diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/EmbedWebViewTheme.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/EmbedWebViewTheme.kt new file mode 100644 index 0000000000..6fbbca5718 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/EmbedWebViewTheme.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplethost + +import android.content.Context +import android.content.res.Configuration +import android.view.ContextThemeWrapper + +/** + * A context that makes a hosted WebView (embedded surface OR full-screen activity) follow the **app** theme + * ("DARK"/"LIGHT") rather than the device. + * + * WebView's dark decision (`prefers-color-scheme` via algorithmic darkening) reads the context's **theme** + * (`?android:attr/isLightTheme`), NOT just the Configuration `uiMode` — and an off-window + * `SurfaceControlViewHost` surface context carries neither the host window's theme nor its night mode. So we + * force the night flag in the Configuration AND wrap it in a DayNight theme whose `isLightTheme` then resolves + * from that flag. + * + * (Verified on device with a standalone repro: `createConfigurationContext` alone — a night Configuration with + * no theme — does NOT flip the renderer; the DayNight `ContextThemeWrapper` is what does it, even across the + * cross-process embedded surface. `setApplicationNightMode` and per-WebView config dispatch do nothing.) + * + * "SYSTEM" (or any unrecognized value) returns [base] unchanged, i.e. follows the device — the host already + * resolves SYSTEM→DARK/LIGHT before handing the theme down for the embedded surfaces. + */ +internal fun nightThemedContext( + base: Context, + themeType: String, +): Context { + val night = + when (themeType) { + "DARK" -> Configuration.UI_MODE_NIGHT_YES + "LIGHT" -> Configuration.UI_MODE_NIGHT_NO + else -> return base + } + val config = + Configuration(base.resources.configuration).apply { + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or night + } + return ContextThemeWrapper(base.createConfigurationContext(config), android.R.style.Theme_DeviceDefault_DayNight) +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 199b8d7961..bfc3c5e812 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -154,7 +154,6 @@ class NappletBrowserActivity : ComponentActivity() { useTor = intent.getBooleanExtra(EXTRA_USE_TOR, true) title = intent.getStringExtra(EXTRA_TITLE).orEmpty() themeType = intent.getStringExtra(EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" } - applyNightMode() if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show() @@ -162,7 +161,9 @@ class NappletBrowserActivity : ComponentActivity() { return } - webView = WebView(this) + // Build the WebView from a context forced to the app theme so its content follows DARK/LIGHT even when + // the device theme differs (WebView reads the context's theme, not the window's — see nightThemedContext). + webView = WebView(nightThemedContext(this, themeType)) configureWebView(webView) webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) webView.dropSystemBarInsets() @@ -657,14 +658,6 @@ class NappletBrowserActivity : ComponentActivity() { addView(ProgressBar(this@NappletBrowserActivity)) } - private fun applyNightMode() { - val uiManager = getSystemService(android.content.Context.UI_MODE_SERVICE) as android.app.UiModeManager - when (themeType) { - "DARK" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_YES - "LIGHT" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_NO - } - } - private fun resolveThemeColor(attr: Int): Int { val tv = android.util.TypedValue() theme.resolveAttribute(attr, tv, true) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 63d5535fa0..5f000eef66 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -143,14 +143,6 @@ class NappletBrowserService : Service() { super.onDestroy() } - private fun applyNightMode(themeType: String) { - val uiManager = getSystemService(android.content.Context.UI_MODE_SERVICE) as android.app.UiModeManager - when (themeType) { - "DARK" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_YES - "LIGHT" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_NO - } - } - private fun tabFor(msg: Message): BrowserTab? = msg.data?.getString(NappletBrowserContract.KEY_SESSION_ID)?.let { tabs[it] } private fun onClientMessage(msg: Message): Boolean { @@ -168,7 +160,6 @@ class NappletBrowserService : Service() { bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, android.graphics.Color.WHITE), themeType = data.getString(NappletBrowserContract.KEY_THEME).orEmpty().ifBlank { "SYSTEM" }, ) - applyNightMode(tab.themeType) tabs[sessionId] = tab // Bind the broker once; a re-sent MSG_CREATE_SESSION (e.g. client reconnect) must not // leak a second binding. @@ -267,7 +258,7 @@ class NappletBrowserService : Service() { // The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId") - val wv = WebView(context) + val wv = WebView(nightThemedContext(context, tab.themeType)) configureWebView(wv, tab) // Theme the pre-load background so a blank/loading page shows Amethyst's background, not white. wv.setBackgroundColor(tab.bgColor) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index 771ff3c470..cbd602b5b7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -205,8 +205,6 @@ class NappletHostActivity : ComponentActivity() { return } - applyNightMode() - if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show() finish() @@ -230,7 +228,9 @@ class NappletHostActivity : ComponentActivity() { // Create + warm the WebView NOW so its (slow, first-in-process) Chromium init runs on the main // thread concurrently with the index probe below (which runs on IO) — instead of serially after // it. Binding the broker early overlaps too. The WebView is attached once the probe succeeds. - webView = WebView(this) + // Built from a context forced to the app theme so its content follows DARK/LIGHT regardless of the + // device theme (WebView reads the context's theme, not the window's — see nightThemedContext). + webView = WebView(nightThemedContext(this, themeType)) hardenWebView(webView) // Theme the WebView's pre-paint background to the app's so it doesn't flash white when the shell // mounts. This activity has a themed context, so it resolves the color locally (no IPC needed). @@ -809,14 +809,6 @@ class NappletHostActivity : ComponentActivity() { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } - private fun applyNightMode() { - val uiManager = getSystemService(android.content.Context.UI_MODE_SERVICE) as android.app.UiModeManager - when (themeType) { - "DARK" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_YES - "LIGHT" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_NO - } - } - private fun resolveThemeColor(attr: Int): Int { val tv = TypedValue() theme.resolveAttribute(attr, tv, true) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index edde38c068..79de4ee87e 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -188,14 +188,6 @@ class NappletHostService : Service() { return true } - private fun applyNightMode(themeType: String) { - val uiManager = getSystemService(android.content.Context.UI_MODE_SERVICE) as android.app.UiModeManager - when (themeType) { - "DARK" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_YES - "LIGHT" -> uiManager.nightMode = android.app.UiModeManager.MODE_NIGHT_NO - } - } - private fun buildTab(msg: Message): NappletTab? { val data = msg.data ?: return null val sessionId = data.getString(NappletEmbedContract.KEY_SESSION_ID) ?: return null @@ -225,7 +217,6 @@ class NappletHostService : Service() { themeType = data.getString(NappletHostContract.EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" }, declaredDomains = declaredDomains, ) - applyNightMode(tab.themeType) return tab } @@ -298,7 +289,7 @@ class NappletHostService : Service() { // The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No napplet tab for session $sessionId") - val wv = WebView(context) + val wv = WebView(nightThemedContext(context, tab.themeType)) val appOrigin = NappletWebContract.appOrigin(deriveAppId(tab.author, tab.identifier)) val effectiveProxy = if (tab.useTor) tab.proxyPort else -1 tab.contentServer = NappletContentServer(tab.paths, tab.servers, effectiveProxy, cacheDir, shellHtml, shimJs, appOrigin, tab.profile, imeProxy = true)