From b680629d587f31fdb51f9a9a4475929e3f8554bf Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 14 Sep 2026 08:02:04 +0000 Subject: [PATCH 1/3] fix(android): harden startup paths that turned a slow datastore into a hang Follow-ups from aw-android#261 (the migration itself is fixed in aw-server-rust#679): - BackgroundService: queue the hostname/legacy-bucket migrations once per process. onStartCommand ran twice on a single launch in the repro, so the blocking migration command was queued twice on the datastore worker. - WebUIFragment: replace the Thread.sleep(100) + immediate reload in onReceivedError with a Handler-based retry that backs off from 250 ms to 5 s and is cancelled in onDestroyView. The old loop blocked the main thread and hammered the server while it was still starting. - WebWatcher: create the bucket off the accessibility service's main thread, like MediaWatcher already does. Play vitals showed 'Executing service WebWatcher' ANRs parked in Datastore::get_buckets. Refs ActivityWatch/aw-android#261 Git-Session-Id: f014c682-2061-5a22-bc8e-de14775b115a --- .../android/BackgroundService.kt | 8 ++++- .../android/fragments/WebUIFragment.kt | 34 +++++++++++++++---- .../android/watcher/WebWatcher.kt | 22 ++++++++---- .../android/fragments/WebUIFragmentTest.kt | 8 +++++ 4 files changed, 58 insertions(+), 14 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt index 334799af..6a3a8333 100644 --- a/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt +++ b/mobile/src/main/java/net/activitywatch/android/BackgroundService.kt @@ -50,6 +50,11 @@ class BackgroundService : Service() { // full initialization when Android kills and recreates the service. private var isFullyStarted = false + // onStartCommand can run more than once per launch (activity start plus a + // sticky/boot restart). The migrations below are blocking datastore-worker + // commands; queueing them twice doubled the startup stall in aw-android#261. + private var migrationsQueued = false + override fun onCreate() { super.onCreate() Log.i(TAG, "BackgroundService created") @@ -118,7 +123,8 @@ class BackgroundService : Service() { val prefs = AWPreferences(this) val needsHostnameMigration = !prefs.hasMigratedHostname() val needsWatcherBucketMigration = !prefs.hasMigratedWatcherAndroidBucketNames() - if (needsHostnameMigration || needsWatcherBucketMigration) { + if ((needsHostnameMigration || needsWatcherBucketMigration) && !migrationsQueued) { + migrationsQueued = true CoroutineScope(Dispatchers.IO).launch { if (needsHostnameMigration) { val hostname = rustInterface.getDeviceName(this@BackgroundService) diff --git a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt index a44ff124..8f438ec1 100644 --- a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt +++ b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt @@ -8,6 +8,8 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.net.Uri import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log import android.view.LayoutInflater import android.view.View @@ -27,7 +29,6 @@ import net.activitywatch.android.R import net.activitywatch.android.ensureDashboardApiKey import org.json.JSONObject import java.io.File -import java.lang.Thread.sleep import java.net.HttpURLConnection import java.net.URI import java.net.URL @@ -38,6 +39,12 @@ private const val TAG = "WebUI" private const val ARG_URL = "url" +// Reload backoff while the local server is still starting (aw-android#261). +internal const val INITIAL_RELOAD_DELAY_MS = 250L +internal const val MAX_RELOAD_DELAY_MS = 5_000L + +internal fun nextReloadDelayMs(current: Long): Long = (current * 2).coerceAtMost(MAX_RELOAD_DELAY_MS) + // Stay under Binder's ~1 MiB transaction limit when shuttling export bodies from JS. internal const val EXPORT_BRIDGE_CHUNK_SIZE = 256 * 1024 @@ -296,6 +303,12 @@ class WebUIFragment : Fragment() { // TODO: Rename and change types of parameters private var listener: OnFragmentInteractionListener? = null private var webView: WebView? = null + private val reloadHandler = Handler(Looper.getMainLooper()) + private var reloadDelayMs = INITIAL_RELOAD_DELAY_MS + private val reloadRunnable = Runnable { + val target = webView ?: return@Runnable + arguments?.getString(ARG_URL)?.let { target.loadUrl(it) } + } private val exportQueue = ExportSaveQueue() private var filePathCallback: ValueCallback>? = null @@ -358,16 +371,17 @@ class WebUIFragment : Fragment() { description: String, failingUrl: String ) { - // Retry + // The local server may still be starting; retry with backoff. + // This used to Thread.sleep() on the main thread and reload + // immediately, which turned a slow server start into a tight + // reload loop on the UI thread (aw-android#261). // TODO: Find way to not show the blinking Android error page Log.e(TAG, "WebView received error: $description") - sleep(100); - arguments?.let { - it.getString(ARG_URL)?.let { it1 -> myWebView.loadUrl(it1) } - } + scheduleReload() } override fun onPageFinished(view: WebView?, url: String?) { + reloadDelayMs = INITIAL_RELOAD_DELAY_MS view?.evaluateJavascript(ANDROID_EXPORT_HOOK_JS, null) } @@ -425,7 +439,15 @@ class WebUIFragment : Fragment() { return view } + private fun scheduleReload() { + val delay = reloadDelayMs + reloadDelayMs = nextReloadDelayMs(reloadDelayMs) + reloadHandler.removeCallbacks(reloadRunnable) + reloadHandler.postDelayed(reloadRunnable, delay) + } + override fun onDestroyView() { + reloadHandler.removeCallbacks(reloadRunnable) filePathCallback?.onReceiveValue(null) filePathCallback = null webView = null diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt index f17af6a5..eecbfd17 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -2,6 +2,7 @@ package net.activitywatch.android.watcher import android.accessibilityservice.AccessibilityService import android.util.Log +import kotlin.concurrent.thread import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import net.activitywatch.android.RustInterface @@ -41,7 +42,7 @@ class WebWatcher : AccessibilityService() { private val bucket_id = "aw-watcher-android-web" private val lastDiagnosticDump = mutableMapOf() - private var ri : RustInterface? = null + @Volatile private var ri : RustInterface? = null private var lastWindowId: Int? = null private val sessionTracker = BrowserSessionTracker() @@ -68,12 +69,19 @@ class WebWatcher : AccessibilityService() { override fun onCreate() { super.onCreate() Log.i(TAG, "Creating WebWatcher") - try { - ri = RustInterface(applicationContext).also { it.createBucketHelper(bucket_id, "web.tab.current") } - } catch (ex: Throwable) { - // Catch Throwable (not just Exception) because System.loadLibrary() throws - // UnsatisfiedLinkError (an Error subclass) when the native library is missing. - Log.e(TAG, "Failed to initialize RustInterface: ${ex.message}") + // createBucketHelper() blocks on the datastore worker. Doing that on the + // accessibility service's main thread produced "Executing service + // WebWatcher" ANRs whenever the worker was busy (aw-android#261), so + // initialize off the main thread; events arriving earlier are dropped by + // the null-safe ri?. calls, same as MediaWatcher. + thread(name = "WebWatcher-init") { + try { + ri = RustInterface(applicationContext).also { it.createBucketHelper(bucket_id, "web.tab.current") } + } catch (ex: Throwable) { + // Catch Throwable (not just Exception) because System.loadLibrary() throws + // UnsatisfiedLinkError (an Error subclass) when the native library is missing. + Log.e(TAG, "Failed to initialize RustInterface: ${ex.message}") + } } } diff --git a/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt b/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt index 0b1b0437..d014db91 100644 --- a/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt @@ -222,4 +222,12 @@ class WebUIFragmentTest { private fun cachedExport(dir: File, name: String, content: String): PendingExport { return PendingExport(name, "application/json", File(dir, name).also { it.writeText(content) }) } + + @Test + fun reloadBackoffDoublesAndCaps() { + assertEquals(500L, nextReloadDelayMs(INITIAL_RELOAD_DELAY_MS)) + assertEquals(4000L, nextReloadDelayMs(2000L)) + assertEquals(MAX_RELOAD_DELAY_MS, nextReloadDelayMs(4000L)) + assertEquals(MAX_RELOAD_DELAY_MS, nextReloadDelayMs(MAX_RELOAD_DELAY_MS)) + } } From 9dbecae6b9c07611b4df52fd98e96d7285ed8aa4 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 14 Sep 2026 08:19:01 +0000 Subject: [PATCH 2/3] fix(webui): only retry on main-frame errors and cancel pending retries on load Greptile on #262: a failed subresource scheduled a full-page reload, and a successful onPageFinished reset the backoff without cancelling it, so a usable dashboard could keep reloading every 250 ms. Use the WebResourceRequest overload, ignore non-main-frame failures, and drop the pending retry once a page finishes loading. Git-Session-Id: f014c682-2061-5a22-bc8e-de14775b115a --- .../android/fragments/WebUIFragment.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt index 8f438ec1..46c7a311 100644 --- a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt +++ b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt @@ -18,6 +18,7 @@ import android.webkit.JavascriptInterface import android.webkit.URLUtil import android.webkit.ValueCallback import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient @@ -367,20 +368,27 @@ class WebUIFragment : Fragment() { class MyWebViewClient : WebViewClient() { override fun onReceivedError( view: WebView, - errorCode: Int, - description: String, - failingUrl: String + request: WebResourceRequest, + error: WebResourceError ) { + // Only a failed main frame warrants a reload; a failed subresource + // (icon, chunk, API call) must not throw away a usable dashboard. + if (!request.isForMainFrame) { + return + } // The local server may still be starting; retry with backoff. // This used to Thread.sleep() on the main thread and reload // immediately, which turned a slow server start into a tight // reload loop on the UI thread (aw-android#261). // TODO: Find way to not show the blinking Android error page - Log.e(TAG, "WebView received error: $description") + Log.e(TAG, "WebView received error: ${error.description}") scheduleReload() } override fun onPageFinished(view: WebView?, url: String?) { + // A page that loaded does not need the retry that a failed + // subresource or an earlier main-frame error may have queued. + reloadHandler.removeCallbacks(reloadRunnable) reloadDelayMs = INITIAL_RELOAD_DELAY_MS view?.evaluateJavascript(ANDROID_EXPORT_HOOK_JS, null) } From cc3c231a989207e0af47a279bb25c7f1f7d0f889 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 14 Sep 2026 08:27:53 +0000 Subject: [PATCH 3/3] fix(webui): keep startup retries through error-page onPageFinished WebView still calls onPageFinished after a failed main-frame load. Cancelling the pending Handler retry there dropped the #261 backoff. Only a successful finish cancels; subresource errors still do not schedule. Git-Session-Id: f014c682-2061-5a22-bc8e-de14775b115a --- .../android/fragments/WebUIFragment.kt | 67 +++++++++++++++---- .../android/fragments/WebUIFragmentTest.kt | 41 ++++++++++++ 2 files changed, 94 insertions(+), 14 deletions(-) diff --git a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt index 46c7a311..a63c7aa6 100644 --- a/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt +++ b/mobile/src/main/java/net/activitywatch/android/fragments/WebUIFragment.kt @@ -6,6 +6,7 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.ApplicationInfo +import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.os.Handler @@ -46,6 +47,45 @@ internal const val MAX_RELOAD_DELAY_MS = 5_000L internal fun nextReloadDelayMs(current: Long): Long = (current * 2).coerceAtMost(MAX_RELOAD_DELAY_MS) +/** + * Decides when a dashboard WebView error should queue a full-page retry. + * + * WebView calls [android.webkit.WebViewClient.onPageFinished] after a failed + * main-frame load as well as a successful one. Cancelling the pending retry + * on every finish would drop the startup backoff (aw-android#261). Only a + * successful finish cancels; subresource errors never schedule a reload. + */ +internal class DashboardReloadPolicy { + var delayMs: Long = INITIAL_RELOAD_DELAY_MS + private set + var currentLoadFailed: Boolean = false + private set + + fun onPageStarted() { + currentLoadFailed = false + } + + /** Delay to schedule, or null if this error must not reload the page. */ + fun onReceivedError(isForMainFrame: Boolean): Long? { + if (!isForMainFrame) { + return null + } + currentLoadFailed = true + val delay = delayMs + delayMs = nextReloadDelayMs(delayMs) + return delay + } + + /** True when a pending retry should be dropped because the page loaded. */ + fun onPageFinished(): Boolean { + if (currentLoadFailed) { + return false + } + delayMs = INITIAL_RELOAD_DELAY_MS + return true + } +} + // Stay under Binder's ~1 MiB transaction limit when shuttling export bodies from JS. internal const val EXPORT_BRIDGE_CHUNK_SIZE = 256 * 1024 @@ -305,7 +345,7 @@ class WebUIFragment : Fragment() { private var listener: OnFragmentInteractionListener? = null private var webView: WebView? = null private val reloadHandler = Handler(Looper.getMainLooper()) - private var reloadDelayMs = INITIAL_RELOAD_DELAY_MS + private val reloadPolicy = DashboardReloadPolicy() private val reloadRunnable = Runnable { val target = webView ?: return@Runnable arguments?.getString(ARG_URL)?.let { target.loadUrl(it) } @@ -366,30 +406,31 @@ class WebUIFragment : Fragment() { webView = myWebView class MyWebViewClient : WebViewClient() { + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + reloadPolicy.onPageStarted() + } + override fun onReceivedError( view: WebView, request: WebResourceRequest, error: WebResourceError ) { - // Only a failed main frame warrants a reload; a failed subresource - // (icon, chunk, API call) must not throw away a usable dashboard. - if (!request.isForMainFrame) { - return - } + val delay = reloadPolicy.onReceivedError(request.isForMainFrame) ?: return // The local server may still be starting; retry with backoff. // This used to Thread.sleep() on the main thread and reload // immediately, which turned a slow server start into a tight // reload loop on the UI thread (aw-android#261). // TODO: Find way to not show the blinking Android error page Log.e(TAG, "WebView received error: ${error.description}") - scheduleReload() + scheduleReload(delay) } override fun onPageFinished(view: WebView?, url: String?) { - // A page that loaded does not need the retry that a failed - // subresource or an earlier main-frame error may have queued. - reloadHandler.removeCallbacks(reloadRunnable) - reloadDelayMs = INITIAL_RELOAD_DELAY_MS + // Error-page finishes still call onPageFinished. Only a + // successful load should drop the pending startup retry. + if (reloadPolicy.onPageFinished()) { + reloadHandler.removeCallbacks(reloadRunnable) + } view?.evaluateJavascript(ANDROID_EXPORT_HOOK_JS, null) } @@ -447,9 +488,7 @@ class WebUIFragment : Fragment() { return view } - private fun scheduleReload() { - val delay = reloadDelayMs - reloadDelayMs = nextReloadDelayMs(reloadDelayMs) + private fun scheduleReload(delay: Long) { reloadHandler.removeCallbacks(reloadRunnable) reloadHandler.postDelayed(reloadRunnable, delay) } diff --git a/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt b/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt index d014db91..a76697bd 100644 --- a/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/fragments/WebUIFragmentTest.kt @@ -230,4 +230,45 @@ class WebUIFragmentTest { assertEquals(MAX_RELOAD_DELAY_MS, nextReloadDelayMs(4000L)) assertEquals(MAX_RELOAD_DELAY_MS, nextReloadDelayMs(MAX_RELOAD_DELAY_MS)) } + + @Test + fun `subresource error does not schedule a reload`() { + val policy = DashboardReloadPolicy() + assertNull(policy.onReceivedError(isForMainFrame = false)) + assertFalse(policy.currentLoadFailed) + assertTrue(policy.onPageFinished()) + assertEquals(INITIAL_RELOAD_DELAY_MS, policy.delayMs) + } + + @Test + fun `main-frame error keeps the pending retry through the error-page finish`() { + val policy = DashboardReloadPolicy() + assertEquals(INITIAL_RELOAD_DELAY_MS, policy.onReceivedError(isForMainFrame = true)) + assertTrue(policy.currentLoadFailed) + assertFalse(policy.onPageFinished()) + assertEquals(500L, policy.delayMs) + } + + @Test + fun `successful load after a retry attempt cancels and resets backoff`() { + val policy = DashboardReloadPolicy() + policy.onReceivedError(isForMainFrame = true) + policy.onPageFinished() + policy.onPageStarted() + assertTrue(policy.onPageFinished()) + assertEquals(INITIAL_RELOAD_DELAY_MS, policy.delayMs) + assertFalse(policy.currentLoadFailed) + } + + @Test + fun `consecutive main-frame errors keep doubling the delay`() { + val policy = DashboardReloadPolicy() + assertEquals(250L, policy.onReceivedError(isForMainFrame = true)) + policy.onPageFinished() + policy.onPageStarted() + assertEquals(500L, policy.onReceivedError(isForMainFrame = true)) + policy.onPageFinished() + policy.onPageStarted() + assertEquals(1000L, policy.onReceivedError(isForMainFrame = true)) + } }