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:
co-authored by
Claude Opus 4.8
parent
6995082334
commit
43aebcca63
+59
@@ -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)
|
||||
}
|
||||
+3
-10
@@ -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)
|
||||
|
||||
+1
-10
@@ -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)
|
||||
|
||||
+3
-11
@@ -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)
|
||||
|
||||
+1
-10
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user