Skip to content

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

Open
FractalMachinist wants to merge 8 commits into
ActivityWatch:masterfrom
FractalMachinist:web-watcher
Open

feat: multi-browser web watcher via Accessibility Service#151
FractalMachinist wants to merge 8 commits into
ActivityWatch: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

greptile-apps Bot commented May 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR replaces the Chrome-only ChromeWatcher accessibility service with a multi-browser WebWatcher supporting Chrome, Firefox, Edge, Opera, and Samsung Internet, adds a Firefox Compose toolbar workaround via manual tree traversal, bumps aw-server-rust to fix a JNI emoji crash, and migrates deprecated onBackPressed overrides to the OnBackPressedDispatcher API.

  • WebWatcher.kt: findNodeByResourceName has the same recycle-before-return bug that was explicitly fixed in findWebView in this same PR — when a direct child matches by name, child.recycle() is called before the identical reference is returned to the caller. Additionally, rootInActiveWindow obtained in extractFirefoxUrl is never recycled; on API 24–32 (the app's minSdk) this leaks one AccessibilityNodeInfo per Firefox event.
  • Test infrastructure: WebWatcherTest and CustomTabsWrapper provide instrumented coverage across browsers; previously flagged issues (FLAGS bitmask, poll vs peek, executeShellCommand close) have been resolved.
  • Build & submodule: Kotlin 1.9, AGP 8.11, Java 11 target, and the aw-server-rust submodule bump are straightforward upgrades.

Confidence Score: 3/5

The core of the PR — WebWatcher's accessibility event handling — has two correctness issues in newly added code that will affect users on Android 7–12 devices.

The findNodeByResourceName function recycles the node it is about to return when the matching node is a direct child, giving the Firefox URL extractor an invalid reference on every Compose-toolbar lookup on API 24–32 devices. This is the identical class of bug the PR explicitly fixed in findWebView, but the fix was not applied to the sister function. Separately, extractFirefoxUrl obtains rootInActiveWindow and never recycles it, leaking a node reference on every Firefox accessibility event on the same API range. Both issues affect the primary new feature (Firefox support).

mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt — specifically findNodeByResourceName (lines 39–51) and extractFirefoxUrl (lines 31–37).

Important Files Changed

Filename Overview
mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt New multi-browser accessibility service replacing ChromeWatcher; contains a recycle-before-return bug in findNodeByResourceName (mirrors the bug fixed in findWebView) and two rootInActiveWindow leaks (extractFirefoxUrl, maybeDumpTree) on API 24–32 devices.
mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt New instrumented test exercising WebWatcher across all installed browsers; uses awaitility for async assertions and custom matcher for event validation. Previously flagged issues have been fixed.
mobile/src/androidTest/java/net/activitywatch/android/watcher/utils/CustomTabsWrapper.kt Test helper driving browser navigation via Custom Tabs; previously flagged issues (FLAGS bitmask, poll vs peek, ParcelFileDescriptor close) have all been addressed.
mobile/src/main/AndroidManifest.xml Replaces ChromeWatcher service registration with WebWatcher; adds MissingLeanbackLauncher and QueryAllPackagesPermission lint suppressions.
aw-server-rust Submodule bumped to pick up the jstring_to_string emoji/non-BMP panic fix (PR #547) that caused silent service crashes on heartbeats with emoji app names.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    AE[onAccessibilityEvent] --> SI{systemui?}
    SI -->|yes| SKIP[return]
    SI -->|no| PKG[resolve packageName]
    PKG --> EX{urlExtractor exists?}
    EX -->|no| WC{windowChanged?}
    WC -->|yes| HU0[handleUrl null, null]
    WC -->|no| SKIP
    EX -->|yes| SRC[event.source != null?]
    SRC -->|no| SKIP
    SRC -->|yes| UE[urlExtractor event]
    UE --> UNL{url == null?}
    UNL -->|yes| DT[maybeDumpTree rate-limited 1/min]
    UNL -->|no| HU[handleUrl newUrl, browser]
    HU --> FWV[findWebView source]
    FWV --> WT[handleWindowTitle]
    WT --> LOG[logBrowserEvent heartbeatHelper]
    DT --> RI[rootInActiveWindow dumpNode tree to logcat]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    AE[onAccessibilityEvent] --> SI{systemui?}
    SI -->|yes| SKIP[return]
    SI -->|no| PKG[resolve packageName]
    PKG --> EX{urlExtractor exists?}
    EX -->|no| WC{windowChanged?}
    WC -->|yes| HU0[handleUrl null, null]
    WC -->|no| SKIP
    EX -->|yes| SRC[event.source != null?]
    SRC -->|no| SKIP
    SRC -->|yes| UE[urlExtractor event]
    UE --> UNL{url == null?}
    UNL -->|yes| DT[maybeDumpTree rate-limited 1/min]
    UNL -->|no| HU[handleUrl newUrl, browser]
    HU --> FWV[findWebView source]
    FWV --> WT[handleWindowTitle]
    WT --> LOG[logBrowserEvent heartbeatHelper]
    DT --> RI[rootInActiveWindow dumpNode tree to logcat]
Loading

Reviews (2): Last reviewed commit: "fix: address greptile's identified bugs ..." | Re-trigger Greptile

@KonradKrol

Copy link
Copy Markdown

I think you mentioned wrong account :)

@0xbrayo

0xbrayo commented May 3, 2026

Copy link
Copy Markdown
Member

Already done in #138 and #139

- Fix memory leak in findNodeByResourceName: recycle child before returning found node
- Fix NPE in exception handling: use ex.message ?: ex.toString() instead of ex.message!!
- Fix Intent flag combination: use 'or' instead of '+' for proper bitwise operation
- Fix queue sync bug: consume NAVIGATION_FINISHED event with poll() instead of peek()
- Fix unclosed file descriptor: close ParcelFileDescriptor from executeShellCommand()

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@FractalMachinist

Copy link
Copy Markdown
Author

Already done in #138 and #139

Hi @0xbrayo, I'm looking forward to #139! #151 adds Firefox Compose toolbar support and diagnostic logging. Since #151 and #139 are both based on #138, they should be easy to integrate.

@FractalMachinist

Copy link
Copy Markdown
Author

@ErikBjare I believe this is ready for review. I don't see how to request your review directly.

@ErikBjare

ErikBjare commented Jun 30, 2026

Copy link
Copy Markdown
Member

@greptileai review

@FractalMachinist I'll do my best to not forget about this, do ping me again if I do! Thanks a lot for contributing!

Comment thread mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt Outdated
Comment thread mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt Outdated
Fixes a real self-recycle bug in the Firefox node-finder that could
silently break URL extraction (or throw on pre-API-33 devices), plus
a handful of correctness/robustness issues found in review:

- Firefox address-bar regex: was non-greedy (truncated URLs containing
  their own ". ") and assumed an ASCII-capitalized hint suffix (broke
  non-Latin locales). Now greedy and locale-independent.
- Blank/cleared address-bar text is now filtered for all browsers, not
  just Firefox, to avoid spurious empty-URL sessions.
- Page title capture no longer depends on URL extraction succeeding in
  the same event.
- Session duration is clamped to zero against backward clock steps
  (NTP sync, manual clock change).
- Normalized protocol-stripping across all browsers/extractors (fixes
  Samsung Internet reporting differently formatted URLs depending on
  which of its two toolbar variants matched).
- Unified the three near-duplicate recursive AccessibilityNodeInfo
  walkers into one generic find/forEach pair, and recycled two
  previously-leaked rootInActiveWindow references.
- Extracted the regex/text-processing and session-state-machine logic
  into pure, unit-testable units (UrlExtraction.kt,
  BrowserSessionTracker.kt) with new JVM tests.
- Fixed CustomTabsWrapper's sticky-fallback/stale-event-queue bug in
  the instrumented test harness, and switched WebWatcherTest to the
  exception-safe RustInterface.getEventsJSON() accessor.

Verified end-to-end: full app builds and unit tests pass, and the
Firefox->URL detection path was confirmed live on a physical device
against a real page load, with the resulting event persisted correctly
to the on-device datastore.

Also documents (TODO(maintainer) comments) two known gaps left
unresolved: the release-build privacy exposure of maybeDumpTree's
logcat output, and why Firefox's page title can't currently be
captured (GeckoView's content isn't a descendant of the toolbar's
accessibility window at all - confirmed via live tree dump).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@FractalMachinist

Copy link
Copy Markdown
Author

Fixed the greptile-flagged bugs.

Also normalized URL formatting across all browsers, not just Firefox — a couple of browsers' own toolbar variants disagreed with each other, which felt in-scope for "browser support" correctness. Added unit tests for the extraction/session logic while in there.

Verified: unit tests pass, and a live device test confirms a real Firefox page load persists correctly end-to-end (checked the on-device datastore directly, not just logs).

Needs your attention:

  • Bucket renamed (aw-watcher-android-web-chromeaw-watcher-android-web), no migration — existing users lose visible history on upgrade.
  • Firefox's page title can't be captured — confirmed live that GeckoView's content sits in a different accessibility window than the toolbar. Needs
    getWindows() work; left as a TODO rather than guess.
  • CI's pinned Node 16 is too old for aw-webui's current deps.

@TimeToBuildBob TimeToBuildBob left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great contribution overall — the test suite is particularly strong: BrowserSessionTrackerTest uses a FakeClock pattern that makes duration math deterministic, the backward-clock clamp test is a nice edge case, and UrlExtractionTest covers Firefox-specific parsing edge cases (locale-independent separator, greedy regex regression). A few things to address before merge:

🔒 Privacy concern — should block merge

WebWatcher.maybeDumpTree logs the full accessibility tree to logcat (via Log.d) in production builds. Log.d calls are not stripped by R8/ProGuard by default, so this will emit potentially sensitive browsing content (page text, content descriptions) to logcat on end-user devices. The contributor's own TODO documents this:

"this can include page text/content-descriptions (potentially sensitive browsing content) and is not currently gated to debug builds"

Suggested fix — enable buildFeatures.buildConfig in mobile/build.gradle and gate the call:

// in mobile/build.gradle:
buildFeatures {
    buildConfig = true
}
// in WebWatcher.kt, before calling maybeDumpTree:
if (BuildConfig.DEBUG) {
    maybeDumpTree(packageName)
}

Alternative with no build config change: if (Log.isLoggable(TAG, Log.DEBUG)) respects adb log-level overrides while not logging in typical production builds.

📁 .idea/ files

.idea/misc.xml and .idea/vcs.xml contain personal Android Studio state and should be reverted. They're usually in .gitignore — if they're not, this is a good time to add them.

📦 aw-server-rust submodule bump

The submodule is bumped from dc70318e1e9c3580. Is this needed for the WebWatcher feature, or is it incidental? Submodule bumps mixed into feature PRs make it harder to bisect and review — better to keep them separate if possible.


Once the privacy gate is in place and the .idea/ changes are reverted, this looks ready. The multi-browser approach via AccessibilityService is the right direction, and the thorough test coverage (unit + E2E) is exactly what this kind of Accessibility-dependent code needs.

@FractalMachinist

FractalMachinist commented Jul 2, 2026

Copy link
Copy Markdown
Author

I strongly agree with the proposed build config. Since build settings reach beyond the ideal scope of this PR, I left it as a decision for @ErikBjare

.idea - not a huge surprise, I had to spin up a new dev environment for this. Thanks for flagging it.

I discuss the submodule bump in my first comment. TL;DR yes, I strongly recommend the bump, and it could be a separate PR.


Erik, tell me what you want done with build configs and submodules, I've got time today.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

@FractalMachinist — thanks for the excellent work and thorough responses. Here's my direction on the outstanding items:

Privacy gate on maybeDumpTree

Go with Log.isLoggable(TAG, Log.DEBUG). It requires zero build config changes — no buildFeatures.buildConfig, no BuildConfig.DEBUG dependency. It works on every API level we target, respects adb log -p log-level filtering for debugging, and doesn't emit anything in normal production use. Minimal diff, maximum effect.

.idea/ files

Already in .gitignore (line 54). These got tracked before the rule existed. Revert them:

git rm --cached .idea/misc.xml .idea/vcs.xml
git commit -m "chore: remove tracked .idea files from version control"

Submodule bump

Keep it. The emoji crash fix (aw-server-rust#547) is important for overall stability and the submodule bump is small and well-scoped. Erik wants to merge this PR and #139 soon — separating it would add delay for marginal reviewer convenience.

Greptile findNodeByResourceName concern

Looking at the current code, findNodeByResourceName no longer exists — the generic findNode in AccessibilityNodeTraversal.kt handles all cases correctly (predicate match returns the node unrecycled, non-matches get recycled). Greptile flagged an older version. ✅ resolved.


Once you've added the Log.isLoggable gate and reverted the .idea/ files, this looks mergeable. Tag me when you push and I'll give it a final sign-off.

@ErikBjare

Copy link
Copy Markdown
Member

Sorry for the conflicts here... I rebase-merged #139 which also builds on the same commits you have, so hopefully a rebase should skip those and the conflict not be too painful to resolve.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

AccessibilityNodeInfo Lifecycle Issue

The Greptile findings are accurate — extractTextByViewId has a resource leak:

Current code (leaks node):

private fun extractTextByViewId(event: AccessibilityEvent, viewId: String): String? =
    processExtractedText(
        event.source?.findAccessibilityNodeInfosByViewId(viewId)?.firstOrNull()?.text?.toString()
    )

Problem: findAccessibilityNodeInfosByViewId allocates new AccessibilityNodeInfo references for each result. The code reads .text but never recycles the node. On API 24–32, this leaks one node per extractTextByViewId call.

Fixed version:

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() }
        }
    }
    return null
}

This follows the same pattern as extractFirefoxUrl, which correctly manages root in a try/finally block. The fix ensures all nodes returned by the API are recycled before returning.


The extractFirefoxUrl implementation is correct (properly recycles in finally), and findNode is well-structured (recursion correctly distinguishes found node from visited children). Only extractTextByViewId needs the fix above.

@TimeToBuildBob

Copy link
Copy Markdown
Contributor

Rebase onto current master

The PR branch is conflicting due to the recent merge of #139. Here's how to rebase cleanly:

# Fetch current master
git fetch origin master

# Rebase only the 8 PR-specific commits (not the entire fork history)
MERGE_BASE=$(git merge-base origin/master HEAD)  # should be 9d32040
git rebase --onto origin/master $MERGE_BASE HEAD

Conflict resolution guide for commit 2 (dep bump):

The build.gradle, gradle/wrapper/gradle-wrapper.properties, and mobile/build.gradle files conflict because master has newer versions. Take master's versions for all three files — master has com.android.tools.build:gradle:8.13.0 vs the PR's 8.11.2, and gradle-8.14 wrapper vs 8.11.

Conflict resolution guide for commit 3 (WebWatcher feature):

WebWatcher.kt and WebWatcherTest.kt are add/add conflicts because master already integrated the initial WebWatcher. Take the PR's version (the multi-browser refactor using BrowserSessionTracker, UrlExtraction, AccessibilityNodeTraversal).

For AndroidManifest.xml: take master's version, then add only the android.software.leanback feature declaration the PR introduces. Do not remove BackgroundService, MediaWatcher, SyncAlarmReceiver, or CategoryTimeWidgetProvider — those were added to master after the fork point and are unrelated to this PR.

For mobile/build.gradle: keep master's version but change minSdkVersion to 24.

Also apply the extractTextByViewId node-recycling fix from the previous comment as a follow-up commit.

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.

6 participants