Skip to content

feat: multi-browser web watcher via Accessibility Service#151

Open
FractalMachinist wants to merge 6 commits intoActivityWatch:masterfrom
FractalMachinist:web-watcher
Open

feat: multi-browser web watcher via Accessibility Service#151
FractalMachinist wants to merge 6 commits intoActivityWatch:masterfrom
FractalMachinist:web-watcher

Conversation

@FractalMachinist
Copy link
Copy Markdown

adding a generic WebWatcher that tracks browser URLs via Android's Accessibility Service. Verified Firefox working on Android 14 (Fairphone 6).

What this adds

  • Replaces the Chrome-only ChromeWatcher with a WebWatcher supporting Chrome, Firefox, Samsung Internet, Opera, and Edge
  • Records url, title, browser, audible, and incognito fields per the web.tab.current event type

Firefox Compose toolbar fix

Firefox ~v130+ migrated its address bar to Jetpack Compose. The original PR's approach (findAccessibilityNodeInfosByViewId("ADDRESSBAR_URL_BOX")) silently returns empty results because Android requires IDs in "package:id/name" format and rejects bare Compose testTag names. Additionally, the toolbar is a sibling of the WebView content area, so searching from event.source never reaches it.

Fix: search from rootInActiveWindow and traverse the tree manually, comparing viewIdResourceName directly. The view-based fallback IDs (url_bar_title, mozac_browser_toolbar_url_view) are retained for older Firefox versions.

Fixes from code review

  • Fixed a bug in findWebView where child.recycle() was called even when child was the node being returned, leaving the caller with a recycled reference (flagged by ellipsis-dev in the original PR).

Testing

Verified on one physical device (Fairphone 6, Android 14) with Firefox 138. Chrome, Edge, Samsung Internet, and Opera have not been tested by the authors of this revision — the view IDs for those browsers are carried over from the original PR unchanged. The diagnostic tree logging (described below) was added specifically because we lack broad device/browser coverage. If you or a reviewer can test on Chrome or Samsung Internet and URL extraction fails, enabling debug logging (adb logcat -s WebWatcher:D) will produce a dump of the accessibility tree that should make it straightforward to identify the correct view IDs. We believe the Firefox fix is correct because it was verified end-to-end (URLs and page titles appearing in the ActivityWatch timeline). We believe the recycle fix is correct by code inspection. For the untested browsers, confidence rests entirely on the original PR author's view IDs being stable across the versions you'll encounter.

Diagnostic logging for unresolved browser issues

The original PR had unresolved reports of Chrome stopping mid-session and Samsung Internet never producing data. The Chrome issue was almost certainly the JNI emoji crash described below — the service would crash silently and stop processing events. For future reports, WebWatcher now dumps the full accessibility tree to logcat (tag: WebWatcher, debug level) when a known browser's URL extractor returns null, rate-limited to once per minute per browser. This gives reporters actionable data without manual instrumentation.

aw-server-rust submodule bump

This PR bumps aw-server-rust to current master to pick up PR #547, which fixes a panic in jstring_to_string when JNI passes modified UTF-8 strings (e.g. app names containing emoji). Without this fix the service crashes silently on any heartbeat where the browser has a non-BMP character in its app name, which likely explains the "Chrome stopped working" report in the original PR thread.

Happy to separate the submodule bump into a standalone PR if you prefer to take it independently.

Credits

Original implementation by @KonradKrol (incubly-oss/aw-android#138).

konrad-krol-incubly and others added 6 commits October 2, 2025 12:25
Picks up PR #547 (merged Dec 2025) which fixes a panic in jstring_to_string
when JNI passes modified UTF-8 strings (e.g. app names containing emoji).
The old CStr::to_str().unwrap() path would abort on surrogate pairs; the
fix uses jstr.into() which handles the JNI encoding correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Firefox ~v130+ uses a Jetpack Compose toolbar where the URL lives in the
content-desc of a node with viewIdResourceName "ADDRESSBAR_URL_BOX".

Two issues prevented the original approach from working:
1. findAccessibilityNodeInfosByViewId silently returns empty for IDs that
   don't contain ":" (requires "package:id/name" format); bare Compose
   testTag names like ADDRESSBAR_URL_BOX are rejected.
2. The toolbar is a sibling of the WebView content area, so searching
   from event.source never reaches it.

Fix: use rootInActiveWindow as search root and traverse the tree manually
with findNodeByResourceName (compares viewIdResourceName directly).

The view-based fallback IDs (url_bar_title, mozac_browser_toolbar_url_view)
are retained for older Firefox versions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If findWebView(child) returns child itself (i.e. the child IS the
matching WebView), the previous .also { child.recycle() } destroyed the
node before the caller could use it. Only recycle when the returned node
is not the child being searched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rowser

When a supported browser's URL extractor returns null, dump the full
accessibility tree to logcat at debug level (tag: WebWatcher), rate-limited
to once per minute per browser package. This gives bug reporters actionable
data — the actual view IDs and content descriptions present — without
requiring manual instrumentation.

Addresses the unresolved Samsung Internet and Chrome reports from the
original PR, where no logs were available to diagnose the failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 2, 2026

Greptile Summary

This PR replaces the Chrome-only ChromeWatcher with a multi-browser WebWatcher that uses the Android Accessibility Service to track URLs across Chrome, Firefox, Samsung Internet, Opera, and Edge. It also includes a Firefox Compose toolbar workaround, a fix for an AccessibilityNodeInfo recycle bug, diagnostic tree logging, a bump of the aw-server-rust submodule to fix a JNI emoji crash, and several housekeeping updates (Kotlin 1.9, AGP 8.11.2, JVM 11, OnBackPressedDispatcher migration).

  • P1 — findNodeByResourceName leaks AccessibilityNodeInfo nodes: the PR explicitly fixes the recycle bug in findWebView but the identical pattern in the new findNodeByResourceName function is missing the if (found !== child) child.recycle() guard, causing a memory leak on every Firefox URL lookup.
  • P1 — ex.message!! NPE in catch block: Exception.message is nullable; the !! will throw a NullPointerException that silently swallows the original exception and stops event processing.
  • P1 — stale NAVIGATION_FINISHED event in test queue: after each navigation, the event is peeked but never consumed, causing every subsequent navigation in the test to fall back to timed waits rather than event-driven synchronization.

Confidence Score: 3/5

Two production P1 bugs and one test P1 bug should be fixed before merging.

Three P1 findings are present: an AccessibilityNodeInfo memory leak in findNodeByResourceName (the same class of bug the PR explicitly fixed in findWebView), a potential NPE in the accessibility event catch block that silently kills event processing, and a test synchronization bug that undermines the reliability of the new instrumentation tests.

WebWatcher.kt (recycle bug + NPE) and CustomTabsWrapper.kt (queue not consumed)

Important Files Changed

Filename Overview
mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt New multi-browser Accessibility Service watcher; findNodeByResourceName has the same AccessibilityNodeInfo recycle bug that was just fixed in findWebView, and ex.message!! can NPE in the catch block.
mobile/src/androidTest/java/net/activitywatch/android/watcher/utils/CustomTabsWrapper.kt Test helper for opening URLs in browsers via CustomTabs; stale NAVIGATION_FINISHED event left in queue after each navigation causes all subsequent navigations to fall back to timed waits; flags use + instead of or.
mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt Instrumentation test for WebWatcher; executeShellCommand returns a ParcelFileDescriptor that is never closed; enableAccessibilityService overwrites the full enabled-services list with no post-test cleanup.
mobile/src/main/java/net/activitywatch/android/watcher/ChromeWatcher.kt Deleted; replaced entirely by WebWatcher.kt.
mobile/src/main/AndroidManifest.xml Service declaration updated from ChromeWatcher to WebWatcher; lint suppressions added for QUERY_ALL_PACKAGES and MissingLeanbackLauncher.
mobile/src/main/java/net/activitywatch/android/MainActivity.kt Migrates deprecated onBackPressed() override to OnBackPressedDispatcher callback; straightforward modernization.
mobile/src/main/java/net/activitywatch/android/OnboardingActivity.kt Migrates deprecated onBackPressed() override to OnBackPressedDispatcher callback; behavior preserved.
build.gradle Bumps Kotlin to 1.9.0, AGP to 8.11.2, and test library versions; straightforward dependency update.
mobile/build.gradle Upgrades to JVM 11, enables core library desugaring, updates AndroidX dependencies, adds CustomTabs/UIAutomator/Awaitility test dependencies.
aw-server-rust Submodule bumped to pick up a fix for JNI modified-UTF-8 panic on emoji in app names.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant WebWatcher
    participant findNodeByResourceName
    participant findWebView
    participant RustInterface

    Browser->>WebWatcher: onAccessibilityEvent(event)
    WebWatcher->>WebWatcher: shouldIgnoreEvent()?
    WebWatcher->>WebWatcher: urlExtractors[packageName]

    alt Known browser (Firefox)
        WebWatcher->>findNodeByResourceName: rootInActiveWindow, "ADDRESSBAR_URL_BOX"
        findNodeByResourceName-->>WebWatcher: urlNode (contentDescription)
        WebWatcher->>WebWatcher: extractFirefoxUrl → newUrl
    else Known browser (Chrome/Edge/etc.)
        WebWatcher->>WebWatcher: extractTextByViewId(event, viewId) → newUrl
    end

    alt newUrl == null
        WebWatcher->>WebWatcher: maybeDumpTree(packageName) [rate-limited 1/min]
    else newUrl != null
        WebWatcher->>WebWatcher: handleUrl(newUrl, browser)
        WebWatcher->>findWebView: event.source
        findWebView-->>WebWatcher: WebView node (title text)
        WebWatcher->>WebWatcher: handleWindowTitle(title)
        WebWatcher->>RustInterface: heartbeatHelper(bucket_id, start, duration, data)
    end
Loading

Comments Outside Diff (5)

  1. mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt, line 801-810 (link)

    P1 findNodeByResourceName has the same recycle bug that was just fixed in findWebView

    When a deep descendant is found, the function returns found without recycling the intermediate child node obtained from getChild(i). Every ancestor along the path to the matching node leaks its AccessibilityNodeInfo. The PR description explicitly calls out fixing this pattern in findWebView but the identical fix is missing here.

  2. mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt, line 890-892 (link)

    P1 ex.message!! throws NPE if exception carries no message

    Exception.message is nullable. Using !! inside a catch block would cause a NullPointerException that swallows the original exception entirely — the service silently stops processing events with no useful log output. Use ex.toString() or a null-safe fallback instead.

  3. mobile/src/androidTest/java/net/activitywatch/android/watcher/utils/CustomTabsWrapper.kt, line 431-436 (link)

    P1 NAVIGATION_FINISHED event is peeked but never consumed, breaking subsequent navigations

    After await { navigationEventsQueue.peek() == NAVIGATION_FINISHED } succeeds, the next line (navigationEventsQueue.peek()) is a no-op — its return value is discarded and the event stays in the queue. When waitForNavigationCompleted is called for the next URL, waitForNavigationStarted calls poll() and immediately drains that stale NAVIGATION_FINISHED, finds it isn't NAVIGATION_STARTED, sets useFallback = true, and all remaining pages fall back to timed waits. Replace the dangling peek() with poll() to consume the event.

  4. mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt, line 241-245 (link)

    P2 executeShellCommand returns an unclosed ParcelFileDescriptor

    UiAutomation.executeShellCommand() returns a ParcelFileDescriptor representing the command's stdout. Not closing it leaks a file descriptor for each shell command call. Close it explicitly after the command completes.

  5. mobile/src/androidTest/java/net/activitywatch/android/watcher/utils/CustomTabsWrapper.kt, line 342 (link)

    P2 Use or instead of + for combining Intent flags

    Flag values are bitmasks; using + is arithmetically coincident here but semantically incorrect — or is the right operator for combining bitmasks and is the idiomatic Kotlin/Android convention.

Reviews (1): Last reviewed commit: "debug: log accessibility tree when URL e..." | Re-trigger Greptile

@KonradKrol
Copy link
Copy Markdown

I think you mentioned wrong account :)

@0xbrayo
Copy link
Copy Markdown
Member

0xbrayo commented May 3, 2026

Already done in #138 and #139

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants