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 @@ -21,6 +21,12 @@ internal class BrowserSessionTracker(
private var lastBrowser: String? = null
private var lastWindowTitle: String? = null

// The browser of the session in progress, if any.
val currentBrowser: String? get() = lastBrowser

// Whether the current page's title has been seen since the url last changed.
val hasTitle: Boolean get() = lastWindowTitle != null

// Returns the just-completed session (previous url/browser/title) when the url or
// browser changes, so the caller can log it. We wait for the url to change before
// logging so we have a chance to receive the page title, which often only arrives
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package net.activitywatch.android.watcher

import android.os.SystemClock

// Minimum time between page-title lookups while the current page's title is unknown, and
// once it has been found (so in-page title changes are still picked up).
internal const val TITLE_LOOKUP_MS = 500L
internal const val TITLE_RECHECK_MS = 5_000L

// WebView title lookups walk the accessibility tree (up to MAX_TRAVERSAL_NODES binder calls
// on the service's main thread), and content-change events arrive up to every 100ms while a
// page is open. This decides when a lookup may run.
internal class TitleLookupGate(
private val now: () -> Long = SystemClock::elapsedRealtime,
) {
private var lastLookup: Long? = null

/**
* Milliseconds to wait before the next lookup: 0 means look up now (and the lookup is
* recorded), null means never for this browser.
*/
fun delayBeforeLookup(browser: String, hasTitle: Boolean): Long? {
// See WebWatcher.findWebView: the lookup never matches Firefox.
if (browser == FIREFOX_PACKAGE) return null
val current = now()
val interval = if (hasTitle) TITLE_RECHECK_MS else TITLE_LOOKUP_MS
val wait = lastLookup?.let { interval - (current - it) } ?: 0L
if (wait > 0) return wait
lastLookup = current
return 0L
}

companion object {
const val FIREFOX_PACKAGE = "org.mozilla.firefox"
}
}

/**
* Whether a pending follow-up lookup already covers a new one for [browser] due at [dueAt]:
* only when it is for the same browser and runs no later.
*/
internal fun keepPendingTitleFollowUp(
pendingBrowser: String,
pendingDueAt: Long,
browser: String,
dueAt: Long,
): Boolean = pendingBrowser == browser && pendingDueAt <= dueAt
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package net.activitywatch.android.watcher

import android.accessibilityservice.AccessibilityService
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
Expand Down Expand Up @@ -48,6 +51,11 @@ class WebWatcher : AccessibilityService() {
@Volatile private var ri : RustInterface? = null
private var lastWindowId: Int? = null
private val sessionTracker = BrowserSessionTracker()
private val titleGate = TitleLookupGate()
private val mainHandler = Handler(Looper.getMainLooper())
// The scheduled follow-up lookup, the browser it is for and when it runs (uptime ms).
private class TitleFollowUp(val browser: String, val dueAt: Long, val runnable: Runnable)
private var titleFollowUp: TitleFollowUp? = null

// heartbeat() blocks on the datastore worker for as long as it is busy. Calling it from
// onAccessibilityEvent ran it on the service's main thread, the same pattern that caused
Expand Down Expand Up @@ -136,9 +144,10 @@ class WebWatcher : AccessibilityService() {
} else {
handleUrl(newUrl, newBrowser = browser)
}
findWebView(source)?.let { webView ->
handleWindowTitle(webView.text.toString())
if (webView !== source) webView.recycle()
when (val delay = titleGate.delayBeforeLookup(browser, sessionTracker.hasTitle)) {
null -> {}
0L -> lookUpTitle(source)
else -> scheduleTitleLookup(browser, delay)
}
} finally {
source.recycle()
Expand All @@ -154,6 +163,40 @@ class WebWatcher : AccessibilityService() {
private fun shouldIgnoreEvent(event: AccessibilityEvent) =
event.packageName == "com.android.systemui"

// Runs a lookup that was skipped by the gate once it is allowed, so a title that
// arrives on a skipped event (with the page quiet afterwards) is still captured.
// One follow-up at a time: a pending one for the same browser that runs no later
// already covers this event. One that runs later (a 5s recheck while a new page needs
// its 500ms retry) or is for a browser the user has left is replaced.
private fun scheduleTitleLookup(browser: String, delayMs: Long) {
val dueAt = SystemClock.uptimeMillis() + delayMs
titleFollowUp?.let { pending ->
if (keepPendingTitleFollowUp(pending.browser, pending.dueAt, browser, dueAt)) return
mainHandler.removeCallbacks(pending.runnable)
}
val followUp = Runnable {
titleFollowUp = null
if (sessionTracker.currentBrowser != browser) return@Runnable
if (titleGate.delayBeforeLookup(browser, sessionTracker.hasTitle) != 0L) return@Runnable
val root = rootInActiveWindow ?: return@Runnable
try {
// A fresh tree: the skipped event's nodes have been recycled by now.
if (root.packageName?.toString() == browser) lookUpTitle(root)
} finally {
root.recycle()
}
}
titleFollowUp = TitleFollowUp(browser, dueAt, followUp)
mainHandler.postAtTime(followUp, dueAt)
}

private fun lookUpTitle(from: AccessibilityNodeInfo) {
findWebView(from)?.let { webView ->
handleWindowTitle(webView.text.toString())
if (webView !== from) webView.recycle()
}
}

// TODO(maintainer): this never finds a match for Firefox, so its page title is never
// captured (logged events show title:""). Confirmed live on-device (2026-07-01, Fenix,
// GeckoView content): dumping the full accessibility tree of rootInActiveWindow while a
Expand Down Expand Up @@ -223,6 +266,7 @@ class WebWatcher : AccessibilityService() {
override fun onInterrupt() {}

override fun onDestroy() {
mainHandler.removeCallbacksAndMessages(null)
// Lets already-queued events finish writing.
writer.shutdown()
super.onDestroy()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package net.activitywatch.android.watcher

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

class TitleLookupGateTest {
private var clock = 10_000L
private val gate = TitleLookupGate { clock }
private val chrome = "com.android.chrome"

@Test
fun firstLookupRunsImmediately() {
assertEquals(0L, gate.delayBeforeLookup(chrome, hasTitle = false))
}

@Test
fun untitledPageIsLookedUpAgainAfterTheShortInterval() {
gate.delayBeforeLookup(chrome, hasTitle = false)
clock += 100
assertEquals(TITLE_LOOKUP_MS - 100, gate.delayBeforeLookup(chrome, hasTitle = false))
clock += TITLE_LOOKUP_MS - 100
assertEquals(0L, gate.delayBeforeLookup(chrome, hasTitle = false))
}

@Test
fun titledPageIsRecheckedOnlyAfterTheLongInterval() {
gate.delayBeforeLookup(chrome, hasTitle = true)
clock += TITLE_LOOKUP_MS
assertEquals(TITLE_RECHECK_MS - TITLE_LOOKUP_MS, gate.delayBeforeLookup(chrome, hasTitle = true))
clock += TITLE_RECHECK_MS - TITLE_LOOKUP_MS
assertEquals(0L, gate.delayBeforeLookup(chrome, hasTitle = true))
}

@Test
fun skippedLookupDoesNotPushTheNextOneBack() {
gate.delayBeforeLookup(chrome, hasTitle = false)
clock += 200
gate.delayBeforeLookup(chrome, hasTitle = false) // skipped
clock += TITLE_LOOKUP_MS - 200
assertEquals(0L, gate.delayBeforeLookup(chrome, hasTitle = false))
}

@Test
fun firefoxIsNeverLookedUp() {
assertNull(gate.delayBeforeLookup(TitleLookupGate.FIREFOX_PACKAGE, hasTitle = false))
}

@Test
fun earlierFollowUpReplacesAPendingRecheck() {
// A 5s recheck for the old page must not hold back the new page's 500ms retry.
assertFalse(keepPendingTitleFollowUp(chrome, pendingDueAt = 5_000L, chrome, dueAt = 500L))
assertTrue(keepPendingTitleFollowUp(chrome, pendingDueAt = 500L, chrome, dueAt = 5_000L))
assertFalse(keepPendingTitleFollowUp("com.brave.browser", 500L, chrome, dueAt = 5_000L))
}
}
Loading