fix(embed): make embedded + full-screen WebViews follow the app theme

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-26 10:49:44 -04:00
co-authored by Claude Opus 4.8
parent 6995082334
commit 43aebcca63
5 changed files with 67 additions and 41 deletions
@@ -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)
}
@@ -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)
@@ -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)
@@ -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)
@@ -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)