From a873dcfbb4757557ccc07fdbc40b30fce37c6f3e Mon Sep 17 00:00:00 2001 From: Brayo Date: Sun, 20 Sep 2026 12:31:56 +0300 Subject: [PATCH 1/2] feat(web-watcher): set audible on browser events from media sessions WebWatcher hard-coded audible=false on every aw-watcher-android-web event. aw-webui uses audible browser events as not-AFK evidence, so the field was worthless on Android. BrowserAudibleDetector reports whether the browser package owns a media session in STATE_PLAYING via MediaSessionManager, which is the precise per-app signal but needs the MediaWatcher notification-listener access. Without that access it falls back to AudioManager.isMusicActive(), which is global and can't tell browser audio from a background music app. The result is cached for one second because accessibility events arrive many times a second while scrolling. BrowserSessionTracker now carries audible per session and splits the session when it flips, so each logged event has the right value for its stretch of time (the desktop web watcher likewise starts a new event when data changes). Url, browser and title carry over across the split. incognito is still hard-coded to false; detecting it needs per-browser accessibility-tree heuristics and is left for a follow-up. --- .../android/watcher/BrowserAudibleDetector.kt | 69 +++++++++++++++++++ .../android/watcher/BrowserSessionTracker.kt | 50 ++++++++++---- .../android/watcher/WebWatcher.kt | 22 ++++-- .../watcher/BrowserAudibleDetectorTest.kt | 20 ++++++ .../watcher/BrowserSessionTrackerTest.kt | 67 ++++++++++++++++++ 5 files changed, 208 insertions(+), 20 deletions(-) create mode 100644 mobile/src/main/java/net/activitywatch/android/watcher/BrowserAudibleDetector.kt create mode 100644 mobile/src/test/java/net/activitywatch/android/watcher/BrowserAudibleDetectorTest.kt diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserAudibleDetector.kt b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserAudibleDetector.kt new file mode 100644 index 00000000..54dff8b1 --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserAudibleDetector.kt @@ -0,0 +1,69 @@ +package net.activitywatch.android.watcher + +import android.content.ComponentName +import android.content.Context +import android.media.AudioManager +import android.media.session.MediaSessionManager +import android.media.session.PlaybackState +import android.os.SystemClock +import android.util.Log + +// Decides the `audible` value for a browser session from the two signals we can get: +// +// * `browserPlaying`: whether the browser package itself owns a media session that is +// currently playing. This is the precise signal (Chrome/Firefox publish a media session +// for page audio/video) but it needs the MediaWatcher notification-listener access, +// so it's null when that isn't granted. +// * `musicActive`: AudioManager.isMusicActive(), which is global (any app) and so can't +// tell browser audio apart from e.g. a music app playing in the background. Only used +// as a coarse fallback when the precise signal is unavailable. +internal fun resolveAudible(browserPlaying: Boolean?, musicActive: Boolean): Boolean = + browserPlaying ?: musicActive + +internal class BrowserAudibleDetector(context: Context) { + private val TAG = "BrowserAudibleDetector" + private val appContext = context.applicationContext + private val listenerComponent = ComponentName(appContext, MediaWatcher::class.java) + private val sessionManager = + appContext.getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager + private val audioManager = + appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + + private var cachedBrowser: String? = null + private var cachedAt = 0L + private var cachedResult = false + + // onAccessibilityEvent fires many times a second while scrolling; getActiveSessions is a + // binder call, so the answer is cached briefly instead of being recomputed per event. + fun isAudible(browserPackage: String): Boolean { + val nowMs = SystemClock.elapsedRealtime() + if (browserPackage == cachedBrowser && nowMs - cachedAt < CACHE_MS) return cachedResult + + cachedResult = resolveAudible( + browserPlaying = browserHasPlayingSession(browserPackage), + musicActive = audioManager?.isMusicActive == true, + ) + cachedBrowser = browserPackage + cachedAt = nowMs + return cachedResult + } + + private fun browserHasPlayingSession(browserPackage: String): Boolean? { + val manager = sessionManager ?: return null + if (!MediaWatcher.isNotificationAccessGranted(appContext)) return null + return try { + manager.getActiveSessions(listenerComponent).any { controller -> + controller.packageName == browserPackage && + controller.playbackState?.state == PlaybackState.STATE_PLAYING + } + } catch (e: SecurityException) { + // Access can be revoked between the settings check and the call. + Log.w(TAG, "Media session access denied: ${e.message}") + null + } + } + + companion object { + private const val CACHE_MS = 1000L + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt index d69d228b..a6d60fd3 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt @@ -7,6 +7,7 @@ internal data class CompletedBrowserSession( val url: String, val browser: String, val title: String, + val audible: Boolean, val start: Instant, val duration: Duration ) @@ -20,33 +21,36 @@ internal class BrowserSessionTracker( private var lastUrl: String? = null private var lastBrowser: String? = null private var lastWindowTitle: String? = null + private var lastAudible: Boolean = false // 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 // after the page loads and/or the user interacts with it. - fun handleUrl(newUrl: String?, newBrowser: String?): CompletedBrowserSession? { + fun handleUrl(newUrl: String?, newBrowser: String?, audible: Boolean = false): CompletedBrowserSession? { if (newUrl == lastUrl && newBrowser == lastBrowser) return null - val completed = lastUrl?.let { url -> - lastBrowser?.let { browser -> - val start = lastUrlTimestamp!! - CompletedBrowserSession( - url = url, - browser = browser, - title = lastWindowTitle ?: "", - start = start, - // Clock can step backward (NTP sync, manual change) between `start` and now; - // don't report a negative duration in that case. - duration = Duration.between(start, now()).coerceAtLeast(Duration.ZERO) - ) - } - } + val completed = completeCurrentSession() lastUrlTimestamp = now() lastUrl = newUrl lastBrowser = newBrowser lastWindowTitle = null + lastAudible = audible + return completed + } + + // Splits the current session when its audible state flips, so the logged events carry + // the right `audible` value for each stretch of time (like the desktop web watcher, + // where a data change starts a new event). The url, browser and title carry over into + // the new session because it's still the same page. + fun handleAudible(audible: Boolean): CompletedBrowserSession? { + if (lastUrl == null || audible == lastAudible) return null + + val completed = completeCurrentSession() + + lastUrlTimestamp = now() + lastAudible = audible return completed } @@ -56,4 +60,20 @@ internal class BrowserSessionTracker( lastWindowTitle = newWindowTitle return true } + + private fun completeCurrentSession(): CompletedBrowserSession? { + val url = lastUrl ?: return null + val browser = lastBrowser ?: return null + val start = lastUrlTimestamp!! + return CompletedBrowserSession( + url = url, + browser = browser, + title = lastWindowTitle ?: "", + audible = lastAudible, + start = start, + // Clock can step backward (NTP sync, manual change) between `start` and now; + // don't report a negative duration in that case. + duration = Duration.between(start, now()).coerceAtLeast(Duration.ZERO) + ) + } } 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 eecbfd17..74ff9432 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -45,6 +45,7 @@ class WebWatcher : AccessibilityService() { @Volatile private var ri : RustInterface? = null private var lastWindowId: Int? = null private val sessionTracker = BrowserSessionTracker() + private lateinit var audibleDetector: BrowserAudibleDetector // Applies stripProtocol uniformly to whatever extractor matched, so the logged url is // formatted identically no matter which browser/view-variant produced it. @@ -69,6 +70,7 @@ class WebWatcher : AccessibilityService() { override fun onCreate() { super.onCreate() Log.i(TAG, "Creating WebWatcher") + audibleDetector = BrowserAudibleDetector(this) // 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 @@ -113,11 +115,14 @@ class WebWatcher : AccessibilityService() { try { val browser = packageName!! val newUrl = extractUrl(browser, event) + val audible = audibleDetector.isAudible(browser) if (newUrl == null) { maybeDumpTree(browser) + // Still on the previous url; only the audible state may have moved. + handleAudible(audible) } else { - handleUrl(newUrl, newBrowser = browser) + handleUrl(newUrl, newBrowser = browser, audible = audible) } findWebView(source)?.let { webView -> handleWindowTitle(webView.text.toString()) @@ -178,9 +183,16 @@ class WebWatcher : AccessibilityService() { } } - private fun handleUrl(newUrl : String?, newBrowser: String?) { - newUrl?.let { Log.i(TAG, "Url: $it, browser: $newBrowser") } - sessionTracker.handleUrl(newUrl, newBrowser)?.let { logBrowserEvent(it) } + private fun handleUrl(newUrl : String?, newBrowser: String?, audible: Boolean = false) { + newUrl?.let { Log.i(TAG, "Url: $it, browser: $newBrowser, audible: $audible") } + sessionTracker.handleUrl(newUrl, newBrowser, audible)?.let { logBrowserEvent(it) } + } + + private fun handleAudible(audible: Boolean) { + sessionTracker.handleAudible(audible)?.let { + Log.i(TAG, "Audible changed to $audible; splitting session") + logBrowserEvent(it) + } } private fun handleWindowTitle(newWindowTitle: String) { @@ -194,7 +206,7 @@ class WebWatcher : AccessibilityService() { .put("url", session.url) .put("browser", session.browser) .put("title", session.title) - .put("audible", false) // TODO + .put("audible", session.audible) .put("incognito", false) // TODO Log.i(TAG, "Registered event: $data") diff --git a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserAudibleDetectorTest.kt b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserAudibleDetectorTest.kt new file mode 100644 index 00000000..636c07eb --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserAudibleDetectorTest.kt @@ -0,0 +1,20 @@ +package net.activitywatch.android.watcher + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class BrowserAudibleDetectorTest { + + @Test + fun `browser media session state wins when available`() { + assertTrue(resolveAudible(browserPlaying = true, musicActive = false)) + assertFalse(resolveAudible(browserPlaying = false, musicActive = true)) + } + + @Test + fun `falls back to global music state without notification access`() { + assertTrue(resolveAudible(browserPlaying = null, musicActive = true)) + assertFalse(resolveAudible(browserPlaying = null, musicActive = false)) + } +} diff --git a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt index cf81caef..374e0e52 100644 --- a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt @@ -1,7 +1,9 @@ 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 import org.threeten.bp.Instant @@ -127,4 +129,69 @@ class BrowserSessionTrackerTest { checkNotNull(completed) assertEquals(0L, completed.duration.seconds) } + @Test + fun `audible defaults to false and is attached to the completed session`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome") + val first = tracker.handleUrl("example.org", "chrome", audible = true) + val second = tracker.handleUrl("example.net", "chrome") + + checkNotNull(first) + assertFalse(first.audible) + checkNotNull(second) + assertTrue(second.audible) + } + + @Test + fun `audible change splits the session and keeps url browser and title`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome", audible = false) + tracker.handleWindowTitle("Example Domain") + clock.advanceSeconds(10) + val silent = tracker.handleAudible(true) + clock.advanceSeconds(20) + val playing = tracker.handleUrl("example.org", "chrome") + + checkNotNull(silent) + assertEquals("example.com", silent.url) + assertEquals("chrome", silent.browser) + assertEquals("Example Domain", silent.title) + assertFalse(silent.audible) + assertEquals(Instant.ofEpochSecond(1000), silent.start) + assertEquals(10L, silent.duration.seconds) + + checkNotNull(playing) + assertEquals("example.com", playing.url) + assertEquals("Example Domain", playing.title) + assertTrue(playing.audible) + assertEquals(Instant.ofEpochSecond(1010), playing.start) + assertEquals(20L, playing.duration.seconds) + } + + @Test + fun `unchanged audible state does not split the session`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome", audible = true) + clock.advanceSeconds(5) + + assertNull(tracker.handleAudible(true)) + } + + @Test + fun `audible without an active session is ignored`() { + val tracker = BrowserSessionTracker() + + assertNull(tracker.handleAudible(true)) + + // Ending a session (window changed away) also leaves nothing to split. + tracker.handleUrl("example.com", "chrome") + tracker.handleUrl(null, null) + assertNull(tracker.handleAudible(true)) + } } From ba6bde04a26d31c176ef8a2cf090ee67ea595a47 Mon Sep 17 00:00:00 2001 From: Brayo Date: Sun, 20 Sep 2026 15:43:58 +0300 Subject: [PATCH 2/2] fix(web-watcher): split on same-url audible change; drop global audio fallback handleUrl returned early when the url and browser were unchanged, so an audible transition sampled on a same-page event was discarded and the session logged with a stale value. Same-page calls now route through handleAudible. The AudioManager.isMusicActive() fallback is removed: it is device-wide, so a background music app would have marked silent browser sessions audible, which aw-webui counts as not-AFK evidence. Without notification access audible is now always false, as before this change. --- .../android/watcher/BrowserAudibleDetector.kt | 40 ++++++++----------- .../android/watcher/BrowserSessionTracker.kt | 3 +- .../android/watcher/WebWatcher.kt | 1 + .../watcher/BrowserAudibleDetectorTest.kt | 20 ---------- .../watcher/BrowserSessionTrackerTest.kt | 20 ++++++++++ 5 files changed, 39 insertions(+), 45 deletions(-) delete mode 100644 mobile/src/test/java/net/activitywatch/android/watcher/BrowserAudibleDetectorTest.kt diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserAudibleDetector.kt b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserAudibleDetector.kt index 54dff8b1..0c83825a 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserAudibleDetector.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserAudibleDetector.kt @@ -2,55 +2,47 @@ package net.activitywatch.android.watcher import android.content.ComponentName import android.content.Context -import android.media.AudioManager import android.media.session.MediaSessionManager import android.media.session.PlaybackState import android.os.SystemClock import android.util.Log -// Decides the `audible` value for a browser session from the two signals we can get: +// Reports whether a browser is currently playing audio, for the `audible` field on +// aw-watcher-android-web events. A browser is considered audible when it owns a media +// session in STATE_PLAYING (Chrome/Firefox publish one for page audio/video). That needs +// the MediaWatcher notification-listener access; without it the answer is always false. // -// * `browserPlaying`: whether the browser package itself owns a media session that is -// currently playing. This is the precise signal (Chrome/Firefox publish a media session -// for page audio/video) but it needs the MediaWatcher notification-listener access, -// so it's null when that isn't granted. -// * `musicActive`: AudioManager.isMusicActive(), which is global (any app) and so can't -// tell browser audio apart from e.g. a music app playing in the background. Only used -// as a coarse fallback when the precise signal is unavailable. -internal fun resolveAudible(browserPlaying: Boolean?, musicActive: Boolean): Boolean = - browserPlaying ?: musicActive - +// AudioManager.isMusicActive() is deliberately not used as a fallback: it's device-wide, +// so a music app in the background would mark silent browser sessions audible, and +// aw-webui treats audible browser events as not-AFK evidence. internal class BrowserAudibleDetector(context: Context) { private val TAG = "BrowserAudibleDetector" private val appContext = context.applicationContext private val listenerComponent = ComponentName(appContext, MediaWatcher::class.java) private val sessionManager = appContext.getSystemService(Context.MEDIA_SESSION_SERVICE) as? MediaSessionManager - private val audioManager = - appContext.getSystemService(Context.AUDIO_SERVICE) as? AudioManager private var cachedBrowser: String? = null private var cachedAt = 0L private var cachedResult = false - // onAccessibilityEvent fires many times a second while scrolling; getActiveSessions is a - // binder call, so the answer is cached briefly instead of being recomputed per event. + // onAccessibilityEvent runs on the service's main thread and fires many times a second + // while scrolling; getActiveSessions is a binder IPC that also builds a MediaController + // per session, and blocking that thread is what produced the WebWatcher ANRs in + // aw-android#261. So the answer is cached briefly instead of being recomputed per event. fun isAudible(browserPackage: String): Boolean { val nowMs = SystemClock.elapsedRealtime() if (browserPackage == cachedBrowser && nowMs - cachedAt < CACHE_MS) return cachedResult - cachedResult = resolveAudible( - browserPlaying = browserHasPlayingSession(browserPackage), - musicActive = audioManager?.isMusicActive == true, - ) + cachedResult = browserHasPlayingSession(browserPackage) cachedBrowser = browserPackage cachedAt = nowMs return cachedResult } - private fun browserHasPlayingSession(browserPackage: String): Boolean? { - val manager = sessionManager ?: return null - if (!MediaWatcher.isNotificationAccessGranted(appContext)) return null + private fun browserHasPlayingSession(browserPackage: String): Boolean { + val manager = sessionManager ?: return false + if (!MediaWatcher.isNotificationAccessGranted(appContext)) return false return try { manager.getActiveSessions(listenerComponent).any { controller -> controller.packageName == browserPackage && @@ -59,7 +51,7 @@ internal class BrowserAudibleDetector(context: Context) { } catch (e: SecurityException) { // Access can be revoked between the settings check and the call. Log.w(TAG, "Media session access denied: ${e.message}") - null + false } } diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt index a6d60fd3..96a935bc 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt @@ -28,7 +28,8 @@ internal class BrowserSessionTracker( // logging so we have a chance to receive the page title, which often only arrives // after the page loads and/or the user interacts with it. fun handleUrl(newUrl: String?, newBrowser: String?, audible: Boolean = false): CompletedBrowserSession? { - if (newUrl == lastUrl && newBrowser == lastBrowser) return null + // Same page: nothing to log for the url, but playback may have started/stopped. + if (newUrl == lastUrl && newBrowser == lastBrowser) return handleAudible(audible) val completed = completeCurrentSession() 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 74ff9432..f0041af2 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -122,6 +122,7 @@ class WebWatcher : AccessibilityService() { // Still on the previous url; only the audible state may have moved. handleAudible(audible) } else { + // Also covers the same-url case: the tracker splits on an audible change. handleUrl(newUrl, newBrowser = browser, audible = audible) } findWebView(source)?.let { webView -> diff --git a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserAudibleDetectorTest.kt b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserAudibleDetectorTest.kt deleted file mode 100644 index 636c07eb..00000000 --- a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserAudibleDetectorTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package net.activitywatch.android.watcher - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class BrowserAudibleDetectorTest { - - @Test - fun `browser media session state wins when available`() { - assertTrue(resolveAudible(browserPlaying = true, musicActive = false)) - assertFalse(resolveAudible(browserPlaying = false, musicActive = true)) - } - - @Test - fun `falls back to global music state without notification access`() { - assertTrue(resolveAudible(browserPlaying = null, musicActive = true)) - assertFalse(resolveAudible(browserPlaying = null, musicActive = false)) - } -} diff --git a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt index 374e0e52..bbaa405a 100644 --- a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt @@ -172,6 +172,26 @@ class BrowserSessionTrackerTest { assertEquals(20L, playing.duration.seconds) } + @Test + fun `same url with a changed audible state splits the session via handleUrl`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome", audible = false) + clock.advanceSeconds(10) + val silent = tracker.handleUrl("example.com", "chrome", audible = true) + clock.advanceSeconds(20) + val playing = tracker.handleUrl("example.org", "chrome") + + checkNotNull(silent) + assertFalse(silent.audible) + assertEquals(10L, silent.duration.seconds) + checkNotNull(playing) + assertEquals("example.com", playing.url) + assertTrue(playing.audible) + assertEquals(20L, playing.duration.seconds) + } + @Test fun `unchanged audible state does not split the session`() { val clock = FakeClock(Instant.ofEpochSecond(1000))