From 60fde7702242b184a912a13d520c72767cdef55a Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Thu, 24 Sep 2026 21:08:53 +0200 Subject: [PATCH 1/3] fix(web): track Brave browser URLs --- .../android/watcher/WebWatcherTest.kt | 5 ++-- .../android/watcher/WebWatcher.kt | 29 ++++++++++++------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt index 2c080f0f..fe592bed 100644 --- a/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt +++ b/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt @@ -120,8 +120,9 @@ data class WebPage(val url: String, val title: String) { expectedBrowser = expectedBrowser, ) - // Samsung Internet does not match title at all as no android.webkit.WebView node is present - private fun shouldMatchTitle(browser: String) = browser != "com.sec.android.app.sbrowser" + // Samsung Internet and Brave do not expose page titles through the WebView node. + private fun shouldMatchTitle(browser: String) = + browser != "com.sec.android.app.sbrowser" && browser != "com.brave.browser" } class WebWatcherEventMatcher( 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..f1fdb9e8 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -8,19 +8,25 @@ import android.view.accessibility.AccessibilityNodeInfo import net.activitywatch.android.RustInterface import org.json.JSONObject -private fun extractTextByViewId(event: AccessibilityEvent, viewId: String): String? { - event.source?.let { source -> - val nodes = source.findAccessibilityNodeInfosByViewId(viewId) - try { - return processExtractedText(nodes.firstOrNull()?.text?.toString()) - } finally { - nodes.forEach { it.recycle() } - source.recycle() - } +private fun extractTextByViewId( + source: AccessibilityNodeInfo?, + viewId: String, + ignoreFocused: Boolean = false, +): String? { + source ?: return null + val nodes = source.findAccessibilityNodeInfosByViewId(viewId) + try { + val node = nodes.firstOrNull()?.takeUnless { ignoreFocused && it.isFocused } + return processExtractedText(node?.text?.toString()) + } finally { + nodes.forEach { it.recycle() } + source.recycle() } - return null } +private fun extractTextByViewId(event: AccessibilityEvent, viewId: String): String? = + extractTextByViewId(event.source, viewId) + class WebWatcher : AccessibilityService() { // The toolbar is a sibling of the content area, so we search from the window root. @@ -50,6 +56,8 @@ class WebWatcher : AccessibilityService() { // formatted identically no matter which browser/view-variant produced it. private fun extractUrl(packageName: String, event: AccessibilityEvent): String? = when (packageName) { "com.android.chrome" -> extractTextByViewId(event, "com.android.chrome:id/url_bar") + // Page events omit Brave's toolbar; the focused address bar may show a search hint. + "com.brave.browser" -> extractTextByViewId(rootInActiveWindow, "com.brave.browser:id/url_bar", ignoreFocused = true) "org.mozilla.firefox" -> // Compose toolbar (current) extractFirefoxUrl(event) @@ -206,6 +214,7 @@ class WebWatcher : AccessibilityService() { companion object { internal val KNOWN_BROWSER_PACKAGES = setOf( "com.android.chrome", + "com.brave.browser", "org.mozilla.firefox", "com.sec.android.app.sbrowser", "com.opera.browser", From e7da6dd838d2633b310497820c43e3c723e31989 Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Thu, 24 Sep 2026 22:54:09 +0200 Subject: [PATCH 2/3] fix(web): close stale session when browser URL is unavailable --- .../android/watcher/WebWatcherTest.kt | 22 ++++++++++++++++++- .../android/watcher/WebWatcher.kt | 17 +++++++------- .../watcher/BrowserSessionTrackerTest.kt | 12 ++++++++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt index fe592bed..edf7d120 100644 --- a/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt +++ b/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt @@ -10,6 +10,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ServiceTestRule +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until import net.activitywatch.android.RustInterface import net.activitywatch.android.watcher.utils.MAX_CONDITION_WAIT_TIME_MILLIS import net.activitywatch.android.watcher.utils.PAGE_MAX_WAIT_TIME_MILLIS @@ -18,6 +21,7 @@ import net.activitywatch.android.watcher.utils.createCustomTabsWrapper import org.awaitility.Awaitility.await import org.hamcrest.TypeSafeMatcher import org.junit.Assume +import org.junit.Assert.assertFalse import org.json.JSONArray import org.json.JSONObject import org.junit.Rule @@ -58,6 +62,18 @@ class WebWatcherTest { browsers.forEach { browser -> openUris(uris = testWebPages.map { it.url }, browser = browser) + val focusedText = if (browser == "com.brave.browser") { + val launchIntent = Intent(Intent.ACTION_VIEW, Uri.parse(testWebPages.first().url)) + .setPackage(browser).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(launchIntent) + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + checkNotNull(device.wait(Until.findObject(By.res("com.brave.browser:id/url_bar")), 5_000)).click() + val bar = device.wait(Until.findObject(By.res("com.brave.browser:id/url_bar").focused(true)), 5_000) + val text = "focus-${System.currentTimeMillis()}.invalid" + checkNotNull(bar).text = text + device.waitForIdle() + text + } else null openHome() // to commit last event val matchers = testWebPages.map { it.toMatcher(browser) } @@ -68,6 +84,10 @@ class WebWatcherTest { matchers.all { matcher -> events.any { matcher.matches(it) } } } + if (focusedText != null) { + val events = ri.getEventsJSON(BUCKET_NAME, 100).asListOfJsonObjects() + assertFalse(events.any { it.getJSONObject("data").optString("url") == focusedText }) + } } } @@ -151,4 +171,4 @@ class WebWatcherEventMatcher( && expectedTitle?.let { it == title } ?: true && browser == expectedBrowser } -} \ No newline at end of file +} 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 f1fdb9e8..fc1e5e1e 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -117,16 +117,17 @@ class WebWatcher : AccessibilityService() { } try { + val browser = packageName!! + val newUrl = extractUrl(browser, event) + if (newUrl == null) { + maybeDumpTree(browser) + if (windowChanged) handleUrl(null, newBrowser = null) + } else { + handleUrl(newUrl, newBrowser = browser) + } + event.source?.let { source -> try { - val browser = packageName!! - val newUrl = extractUrl(browser, event) - - if (newUrl == null) { - maybeDumpTree(browser) - } else { - handleUrl(newUrl, newBrowser = browser) - } findWebView(source)?.let { webView -> handleWindowTitle(webView.text.toString()) if (webView !== source) webView.recycle() 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..e4252bf2 100644 --- a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt +++ b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt @@ -65,6 +65,18 @@ class BrowserSessionTrackerTest { assertEquals("chrome", completed.browser) } + @Test + fun `unavailable url ends the previous browser session`() { + val tracker = BrowserSessionTracker() + tracker.handleUrl("example.com", "chrome") + + val completed = tracker.handleUrl(null, null) + + checkNotNull(completed) + assertEquals("example.com", completed.url) + assertEquals("chrome", completed.browser) + } + @Test fun `title set before the url changes is attached to the completed session`() { val clock = FakeClock(Instant.ofEpochSecond(1000)) From 29f08c03cb57ea86997f7cd28b36713a74fafa81 Mon Sep 17 00:00:00 2001 From: Nikita Maximilian Grimm Date: Thu, 24 Sep 2026 22:58:18 +0200 Subject: [PATCH 3/3] test(web): keep accessibility active during Brave focus check --- .../android/watcher/WebWatcherTest.kt | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt index edf7d120..d5073c53 100644 --- a/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt +++ b/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt @@ -11,6 +11,7 @@ import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.ServiceTestRule import androidx.test.uiautomator.By +import androidx.test.uiautomator.Configurator import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.Until import net.activitywatch.android.RustInterface @@ -62,18 +63,7 @@ class WebWatcherTest { browsers.forEach { browser -> openUris(uris = testWebPages.map { it.url }, browser = browser) - val focusedText = if (browser == "com.brave.browser") { - val launchIntent = Intent(Intent.ACTION_VIEW, Uri.parse(testWebPages.first().url)) - .setPackage(browser).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(launchIntent) - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - checkNotNull(device.wait(Until.findObject(By.res("com.brave.browser:id/url_bar")), 5_000)).click() - val bar = device.wait(Until.findObject(By.res("com.brave.browser:id/url_bar").focused(true)), 5_000) - val text = "focus-${System.currentTimeMillis()}.invalid" - checkNotNull(bar).text = text - device.waitForIdle() - text - } else null + val focusedText = if (browser == "com.brave.browser") typeInFocusedBraveBar() else null openHome() // to commit last event val matchers = testWebPages.map { it.toMatcher(browser) } @@ -91,6 +81,27 @@ class WebWatcherTest { } } + private fun typeInFocusedBraveBar(): String { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(testWebPages.first().url)) + .setPackage("com.brave.browser").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + + val configurator = Configurator.getInstance() + val previousFlags = configurator.uiAutomationFlags + configurator.uiAutomationFlags = FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES + return try { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + checkNotNull(device.wait(Until.findObject(By.res("com.brave.browser:id/url_bar")), 5_000)).click() + val bar = device.wait(Until.findObject(By.res("com.brave.browser:id/url_bar").focused(true)), 5_000) + val text = "focus-${System.currentTimeMillis()}.invalid" + checkNotNull(bar).text = text + device.waitForIdle() + text + } finally { + configurator.uiAutomationFlags = previousFlags + } + } + private fun enableAccessibilityService(serviceName: String) { executeShellCmd("settings put secure enabled_accessibility_services $serviceName") executeShellCmd("settings put secure accessibility_enabled 1")