fix(android): follow the mc-webui theme without a restart

The system bar strips took their colour from the page only when a page
finished loading or the app came back to the foreground. mc-webui switches
its theme by flipping data-theme on the document element - no reload, no
trip through onResume - so switching to the dark theme left a white strip
under the status bar until the app was restarted.

A MutationObserver on that attribute closes the gap. It lives entirely on
this side, so mc-webui still needs no changes, and it reports twice: once
immediately and once after 300 ms, because the theme change animates and
an immediate read catches a colour mid-transition.

The callback arrives through a second, separate JavaScript bridge rather
than an extra method on the notification one. That bridge is deliberately
minimal, and a one-way "here is a colour" channel has no business sharing
a surface with notification permissions.

Verified on a real device: the strips now follow the theme both ways
without leaving the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWoandClaude Opus 5 committed 2026-08-20 08:15:40 +02:00
1 parent adb9efc17a
commit a36fe9ebb7
1 file changed
+67 -1
@@ -73,6 +73,8 @@ class MainActivity : AppCompatActivity() {
private val notificationBridge = NotificationBridge()
private val chromeBridge = ChromeBridge()
/** Ids of `Notification.requestPermission()` promises awaiting the Android dialog. */
private val pendingPermissionCallbacks = mutableListOf<String>()
@@ -221,6 +223,18 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* Asking once per page load is not enough: mc-webui switches its theme by
* flipping `data-theme` on the document element, with no reload and no
* trip through onResume, so the strips would keep the old colour until the
* app was restarted. Watching that attribute closes the gap, and it lives
* entirely on this side - mc-webui needs no change.
*/
private fun watchPageBackground() {
if (webView.visibility != View.VISIBLE) return
webView.evaluateJavascript(BACKGROUND_WATCHER, null)
}
/**
* Turns `"rgb(18, 18, 18)"` - the quoted form evaluateJavascript hands back -
* into a colour. Anything unrecognised returns null and leaves the strips
@@ -269,7 +283,7 @@ class MainActivity : AppCompatActivity() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
matchSystemBarsToPage()
watchPageBackground()
}
// Deprecated, but it is the only one Android 5.x calls
@@ -436,6 +450,7 @@ class MainActivity : AppCompatActivity() {
*/
private fun installNotificationShim() {
webView.addJavascriptInterface(notificationBridge, BRIDGE_NAME)
webView.addJavascriptInterface(chromeBridge, CHROME_BRIDGE_NAME)
pageStartScript = try {
assets.open(SHIM_ASSET).bufferedReader().use { it.readText() }
} catch (e: Exception) {
@@ -514,6 +529,18 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* Deliberately separate from [NotificationBridge] and deliberately one-way:
* the page can hand over a colour string and nothing else.
*/
private inner class ChromeBridge {
@JavascriptInterface
fun reportBackground(colour: String) = runOnUiThread {
parseCssColor(colour)?.let { applyChromeColor(it) }
}
}
// areNotificationsEnabled() is false whenever POST_NOTIFICATIONS is missing,
// so the guard below is the permission check - lint just cannot see that
@SuppressLint("MissingPermission")
@@ -637,6 +664,7 @@ class MainActivity : AppCompatActivity() {
private const val REQ_NOTIFICATIONS = 3
private const val BRIDGE_NAME = "__mcNotifyBridge"
private const val CHROME_BRIDGE_NAME = "__mcChromeBridge"
private const val SHIM_ASSET = "notification_shim.js"
private const val CHANNEL_ID = "mc_webui_activity"
private const val NOTIFICATION_ID = 1
@@ -662,5 +690,43 @@ class MainActivity : AppCompatActivity() {
return null;
})()
"""
/**
* Reports the page background now and again whenever mc-webui flips its
* theme. The delayed second report is there because the theme change
* animates: read too early and the colour is still mid-transition.
*/
private const val BACKGROUND_WATCHER = """
(function () {
if (window.__mcChromeWatching) return;
window.__mcChromeWatching = true;
var read = function () {
var elements = [document.body, document.documentElement];
for (var i = 0; i < elements.length; i++) {
if (!elements[i]) continue;
var colour = getComputedStyle(elements[i]).backgroundColor;
if (!colour || colour === 'transparent') continue;
var parts = colour.replace(/[^0-9.,]/g, '').split(',');
if (parts.length > 3 && parseFloat(parts[3]) === 0) continue;
return colour;
}
return null;
};
var report = function () {
try {
var colour = read();
if (colour) __mcChromeBridge.reportBackground(colour);
} catch (e) {}
};
new MutationObserver(function () {
report();
setTimeout(report, 300);
}).observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-bs-theme']
});
report();
})()
"""
}
}