Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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<Array<Uri>>? = null

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,7 +42,7 @@ class WebWatcher : AccessibilityService() {
private val bucket_id = "aw-watcher-android-web"
private val lastDiagnosticDump = mutableMapOf<String, Long>()

private var ri : RustInterface? = null
@Volatile private var ri : RustInterface? = null
private var lastWindowId: Int? = null
private val sessionTracker = BrowserSessionTracker()

Expand All @@ -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}")
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
Loading