From 40907d54f210651a116e6a9264d0f7271b737b37 Mon Sep 17 00:00:00 2001 From: Brayo Date: Sun, 20 Sep 2026 12:31:56 +0300 Subject: [PATCH 1/3] 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 959f35ea..8ca2978b 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,6 +21,7 @@ internal class BrowserSessionTracker( private var lastUrl: String? = null private var lastBrowser: String? = null private var lastWindowTitle: String? = null + private var lastAudible: Boolean = false // The browser of the session in progress, if any. val currentBrowser: String? get() = lastBrowser @@ -31,28 +33,30 @@ internal class BrowserSessionTracker( // 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 } @@ -62,4 +66,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 fb47f6d2..b4332f4f 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -70,6 +70,7 @@ class WebWatcher : AccessibilityService() { if (!executor.isShutdown) Log.w(TAG, "Datastore stalled; dropping a browser event") }, ) + 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. @@ -94,6 +95,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 @@ -138,11 +140,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) } when (val delay = titleGate.delayBeforeLookup(browser, sessionTracker.hasTitle)) { null -> {} @@ -238,9 +243,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) { @@ -254,7 +266,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 a8797a88783fdf23267c93903bf030cf606a6147 Mon Sep 17 00:00:00 2001 From: Brayo Date: Sun, 20 Sep 2026 15:43:58 +0300 Subject: [PATCH 2/3] 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 8ca2978b..1c1bb270 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt @@ -34,7 +34,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 b4332f4f..8ce5de43 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -147,6 +147,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) } when (val delay = titleGate.delayBeforeLookup(browser, sessionTracker.hasTitle)) { 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)) From f858af3255317e98672468213120a3cee8fceedf Mon Sep 17 00:00:00 2001 From: Brayo Date: Sun, 11 Oct 2026 22:33:59 +0300 Subject: [PATCH 3/3] fix(web-watcher): apply audible changes only to the current browser's session An event from a browser just switched to can arrive before its url is extractable. Its audible state was applied to the previous browser's session, so a silent page could be logged as playing. handleAudible now takes the browser and ignores any other than the current session's. --- .../android/watcher/BrowserSessionTracker.kt | 10 +++++---- .../android/watcher/WebWatcher.kt | 6 +++--- .../watcher/BrowserSessionTrackerTest.kt | 21 +++++++++++++++---- 3 files changed, 26 insertions(+), 11 deletions(-) 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 1c1bb270..dd7c0073 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt @@ -35,7 +35,7 @@ internal class BrowserSessionTracker( // after the page loads and/or the user interacts with it. fun handleUrl(newUrl: String?, newBrowser: String?, audible: Boolean = false): CompletedBrowserSession? { // Same page: nothing to log for the url, but playback may have started/stopped. - if (newUrl == lastUrl && newBrowser == lastBrowser) return handleAudible(audible) + if (newUrl == lastUrl && newBrowser == lastBrowser) return handleAudible(newBrowser, audible) val completed = completeCurrentSession() @@ -50,9 +50,11 @@ internal class BrowserSessionTracker( // 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 + // the new session because it's still the same page. [browser] is the browser the audible + // state was read for; a different one (an event from a browser just switched to, before + // its url is known) must not change the current session. + fun handleAudible(browser: String?, audible: Boolean): CompletedBrowserSession? { + if (lastUrl == null || browser != lastBrowser || audible == lastAudible) return null 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 8ce5de43..acd5589b 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -145,7 +145,7 @@ class WebWatcher : AccessibilityService() { if (newUrl == null) { maybeDumpTree(browser) // Still on the previous url; only the audible state may have moved. - handleAudible(audible) + handleAudible(browser, audible) } else { // Also covers the same-url case: the tracker splits on an audible change. handleUrl(newUrl, newBrowser = browser, audible = audible) @@ -249,8 +249,8 @@ class WebWatcher : AccessibilityService() { sessionTracker.handleUrl(newUrl, newBrowser, audible)?.let { logBrowserEvent(it) } } - private fun handleAudible(audible: Boolean) { - sessionTracker.handleAudible(audible)?.let { + private fun handleAudible(browser: String, audible: Boolean) { + sessionTracker.handleAudible(browser, audible)?.let { Log.i(TAG, "Audible changed to $audible; splitting session") logBrowserEvent(it) } 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 bbaa405a..7052abaf 100644 --- a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt @@ -2,6 +2,7 @@ package net.activitywatch.android.watcher import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -152,7 +153,7 @@ class BrowserSessionTrackerTest { tracker.handleUrl("example.com", "chrome", audible = false) tracker.handleWindowTitle("Example Domain") clock.advanceSeconds(10) - val silent = tracker.handleAudible(true) + val silent = tracker.handleAudible("chrome", true) clock.advanceSeconds(20) val playing = tracker.handleUrl("example.org", "chrome") @@ -200,18 +201,30 @@ class BrowserSessionTrackerTest { tracker.handleUrl("example.com", "chrome", audible = true) clock.advanceSeconds(5) - assertNull(tracker.handleAudible(true)) + assertNull(tracker.handleAudible("chrome", true)) } @Test fun `audible without an active session is ignored`() { val tracker = BrowserSessionTracker() - assertNull(tracker.handleAudible(true)) + assertNull(tracker.handleAudible("chrome", 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)) + assertNull(tracker.handleAudible("chrome", true)) + } + + @Test + fun `audible state from another browser does not touch the current session`() { + val tracker = BrowserSessionTracker() + + tracker.handleUrl("example.com", "chrome", audible = false) + + // Switched to a playing Firefox whose url isn't extractable yet. + assertNull(tracker.handleAudible("org.mozilla.firefox", true)) + // Chrome's session is still silent, so a real change for it still splits. + assertNotNull(tracker.handleAudible("chrome", true)) } }