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..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,8 +6,11 @@ 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 +import android.os.Looper import android.util.Log import android.view.LayoutInflater import android.view.View @@ -16,6 +19,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 @@ -27,7 +31,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 +41,51 @@ 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) + +/** + * 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 @@ -296,6 +344,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 val reloadPolicy = DashboardReloadPolicy() + 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 @@ -352,22 +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, - errorCode: Int, - description: String, - failingUrl: String + request: WebResourceRequest, + error: WebResourceError ) { - // Retry + 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: $description") - sleep(100); - arguments?.let { - it.getString(ARG_URL)?.let { it1 -> myWebView.loadUrl(it1) } - } + Log.e(TAG, "WebView received error: ${error.description}") + scheduleReload(delay) } override fun onPageFinished(view: WebView?, url: String?) { + // 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) } @@ -425,7 +488,13 @@ class WebUIFragment : Fragment() { return view } + private fun scheduleReload(delay: Long) { + 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..a76697bd 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,53 @@ 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)) + } + + @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)) + } }