fix(android): nudge the page, because nothing else will

Measured on a Galaxy S20 FE with the service running: the app process, the
service and the WebView's own renderer process all stayed alive for an hour and
a half, and the page still stopped after 56 minutes. Notifications were posted
correctly from 11:29 to 12:25 - 45 of them, traced through the PendingIntent
each one creates - and then nothing, while the server went on emitting messages
at 12:28:46, 12:28:53 and 12:29:12. The server saw the client time out at
12:29:52 and no reconnection was attempted for the next 29 minutes, although
socket.io retries forever by default. Delivery came back only when the activity
was resumed.

Ruled out with evidence rather than reasoning: Doze (the phone was charging and
only reached full at 13:05), Wi-Fi (wpa_supplicant heartbeats every 10s, no
drop), Samsung's Freecess freezer (it froze a dozen other apps by name that
afternoon and never this one - the foreground service did its job), a killed
renderer or app process (neither appears in any kill line), and socket.io giving
up (reconnectionAttempts defaults to Infinity).

What is left is that Chromium suspends the work of a hidden page. A foreground
service buys the process; it does not make Chromium run the page inside it. That
gap is not something the Stage B plan knew about.

So the service now nudges the page every two minutes. evaluateJavascript is the
lever: injected script does not queue behind the throttled timers the page's own
setTimeout calls sit in. The script re-opens the socket if it has died, which is
what mc-webui would have done a second later had its timers been running, and it
reads mc-webui's own chatSocket rather than anything added for this, so it works
against whichever server version the user typed in.

The wake logs one line every two minutes under the McPageWake tag. That is
deliberate: it is the only way to tell "the page answered" from "the page never
ran the script" after the fact, which is exactly what the next measurement has
to distinguish.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWoandClaude Opus 5 committed 2026-09-06 13:31:40 +02:00
1 parent d14ebd6994
commit 8b5ed4ceb5
1 file changed
+78 -1
@@ -18,6 +18,9 @@ import android.net.http.SslError
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.webkit.CookieManager
import android.webkit.HttpAuthHandler
@@ -97,6 +100,16 @@ class MainActivity : AppCompatActivity() {
/** Set by [connect]; drops the pages of whichever server we just left. */
private var clearHistoryOnLoad = false
private val pageWakeHandler = Handler(Looper.getMainLooper())
/** Reschedules itself for as long as the background service is wanted. */
private val pageWakeTick = object : Runnable {
override fun run() {
wakePage()
pageWakeHandler.postDelayed(this, WAKE_INTERVAL_MS)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Before setContentView, as androidx asks. Transparent rather than the
@@ -175,6 +188,7 @@ class MainActivity : AppCompatActivity() {
*/
override fun onDestroy() {
super.onDestroy()
pageWakeHandler.removeCallbacks(pageWakeTick)
MeshWatchService.stop(this)
}
@@ -740,6 +754,31 @@ class MainActivity : AppCompatActivity() {
* to call from anywhere and as often as convenient: both directions are
* no-ops once the service is already in the state being asked for.
*/
/**
* Nudges the page, because nothing else will.
*
* Measured on a real device: with the service running, the process, the
* service and the WebView's own renderer process all stayed alive for an
* hour and a half - and the page still stopped after 56 minutes. Chromium
* had suspended its work. A foreground service buys the process; it does
* not make Chromium run a hidden page.
*
* `evaluateJavascript` is the lever, because injected script does not wait
* in the throttled timer queues the page's own `setTimeout` calls sit in.
* The script re-opens the socket if it has died, which is what mc-webui
* would have done by itself a second later had its timers been running.
*/
private fun wakePage() {
if (webView.visibility != View.VISIBLE) return
// Global and idempotent, and free when nothing was paused
webView.resumeTimers()
webView.evaluateJavascript(PAGE_WAKE) { state ->
// One line every two minutes, and the only way to tell "the page
// answered" from "the page never ran the script" after the fact
Log.d(WAKE_TAG, "wake -> $state")
}
}
private fun updateWatchService() {
// Android's own switches have the last word: a revoked permission or a
// channel turned off there leaves nothing for the service to show, and
@@ -747,7 +786,13 @@ class MainActivity : AppCompatActivity() {
// one thing worse than not raising them
val allowed = notificationsWanted &&
NotificationManagerCompat.from(this).areNotificationsEnabled()
if (allowed) MeshWatchService.start(this) else MeshWatchService.stop(this)
pageWakeHandler.removeCallbacks(pageWakeTick)
if (allowed) {
MeshWatchService.start(this)
pageWakeHandler.postDelayed(pageWakeTick, WAKE_INTERVAL_MS)
} else {
MeshWatchService.stop(this)
}
}
/**
@@ -896,6 +941,38 @@ class MainActivity : AppCompatActivity() {
// without the form turning into a page of its own
private const val HISTORY_LIMIT = 6
/**
* How often the page gets nudged while the app is away. Short enough
* that a stalled page costs one notification rather than every one
* after it, long enough to be invisible on the battery.
*/
private const val WAKE_INTERVAL_MS = 2 * 60 * 1000L
private const val WAKE_TAG = "McPageWake"
/**
* Asks the page how its connection is and puts it back if it has gone.
* Reads mc-webui's own `chatSocket` rather than anything added for this,
* so it works against whichever version of the server the user typed in;
* where that name is missing the script simply reports so, and having
* run at all is still what un-stalls the page.
*/
private const val PAGE_WAKE = """
(function () {
try {
if (typeof chatSocket === 'undefined' || !chatSocket) return 'no-socket';
if (chatSocket.connected) return 'connected';
try {
chatSocket.connect();
return 'reopened';
} catch (e) {
return 'reopen-failed';
}
} catch (e) {
return 'error';
}
})()
"""
private const val BRIDGE_NAME = "__mcNotifyBridge"
private const val CHROME_BRIDGE_NAME = "__mcChromeBridge"
private const val SHIM_ASSET = "notification_shim.js"