Skip to content

fix: refresh Android snapshots after in-place ComposeView content swaps (#1254)#1259

Merged
thymikee merged 1 commit into
mainfrom
claude/agent-device-1254-210583
Jul 15, 2026
Merged

fix: refresh Android snapshots after in-place ComposeView content swaps (#1254)#1259
thymikee merged 1 commit into
mainfrom
claude/agent-device-1254-210583

Conversation

@thymikee

Copy link
Copy Markdown
Member

Summary

Fixes #1254 — after the second Jetpack Navigation 3 navigation, agent-device snapshot keeps returning the previous screen. Every selector on the new screen then fails to match (click, fill, is, wait, find) even though the app has actually moved on. adb shell uiautomator dump at the same instant shows the correct screen, so it's the helper's snapshot that is stale, not the app or device.

Root cause

The snapshot helper keeps a single UiAutomation connection hot across captures in a persistent session (a deliberate "keep the runner hot" optimization). That connection's per-connection accessibility node cache is not reliably invalidated by an in-place content swap that keeps the same window/Activity — which is exactly what Navigation 3 does: every destination is rendered inside one AndroidComposeView, with no new Activity or window (mCurrentFocus never changes). So getWindows() / getRootInActiveWindow() keep serving the cached subtree from the earlier navigation.

uiautomator dump is unaffected because each invocation uses a fresh connection with an empty cache.

The #861 recovery only rescues empty / system-only / content-poor helper output and falls back to a stock uiautomator dump. Here the stale tree is a rich, plausible foreground-app screen (16 nodes from the app package), so it slips past every heuristic.

Fix

Clear the accessibility cache before every traversal so each capture re-reads the live tree — the same effect a fresh uiautomator dump gets for free:

  • API 34+: UiAutomation.clearCache() (public since Android 14).
  • older: best-effort reflection into AccessibilityInteractionClient (all failures swallowed; capture proceeds unchanged if unavailable).

The change is confined to the on-device helper (SnapshotInstrumentation.java); the npm-bundled APK is rebuilt from source at prepack.

Verification

Reproduced end-to-end with the reporter's minimal repro (antFrancon/nav3-agent-device-snapshot-repro) on an Android emulator (API 36).

Before (unpatched helper):

snapshot after click id=to-screen3  ->  "Screen 2" + to-screen3   (STALE)
uiautomator dump (same instant)     ->  screen3-title, screen3-field (correct)

After (patched helper) — Home -> Screen 2 -> Screen 3, snapshot correctly lands on Screen 3 (screen3-field, "Type here"):

run 1: PASS (Screen 3)
run 2: PASS (Screen 3)
run 3: PASS (Screen 3)

Home and the first navigation continued to capture correctly, and the Android content-recovery unit tests still pass.

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB 0 B
JS gzip 550.9 kB 550.9 kB 0 B
npm tarball 664.4 kB 664.4 kB 0 B
npm unpacked 2.3 MB 2.3 MB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 26.8 ms 27.0 ms +0.2 ms
CLI --help 57.9 ms 57.4 ms -0.5 ms

Top changed chunks: no changes in the largest emitted chunks.

@thymikee

Copy link
Copy Markdown
Member Author

One compatibility blocker in the pre-34 fallback: Android 13 changed AccessibilityInteractionClient.clearCache() to clearCache(int connectionId), but this code only reflects the no-arg signature. On API 33, getMethod("clearCache") therefore throws and is silently swallowed; UiAutomation.clearCache() is unavailable there, so the stale cache remains. AOSP API 33 also exposes hidden UiAutomation.getConnectionId() for this legacy tooling path.

Please handle the API 33 connection-scoped signature (while retaining the older no-arg fallback), then verify at least the reflective path on API 33. The API 36 before/after evidence is otherwise strong, and the clear ordering before both traversal paths looks correct.

AOSP references: https://android.googlesource.com/platform/frameworks/base/+/android-13.0.0_r1/core/java/android/view/accessibility/AccessibilityInteractionClient.java and https://android.googlesource.com/platform/frameworks/base/+/android-13.0.0_r1/core/java/android/app/UiAutomation.java

The snapshot helper keeps one UiAutomation connection hot across captures in
a persistent session. Its per-connection accessibility node cache is not
always invalidated by an in-place content swap that keeps the same
window/Activity — e.g. Jetpack Navigation 3, which renders every destination
inside a single AndroidComposeView. After the second such navigation
getWindows()/getRootInActiveWindow() keep serving the previous screen, so
every selector on the new screen fails to match even though the app has moved
on. `uiautomator dump` is unaffected because it uses a fresh connection with
an empty cache each time.

The #861 recovery only rescues empty/system-only/content-poor captures, so a
stale-but-plausible app screen slips past it. Fix the root cause instead:
drop the cache before every traversal. On API 34+ use the documented
UiAutomation.clearCache(). On older platforms, which lack it, re-apply the
current service info via UiAutomation.setServiceInfo() — a public API that
internally calls AccessibilityInteractionClient.clearCache() (verified in
AOSP from API 24 through 33). The internal cache API cannot be reflected
directly: it is a blocklisted non-SDK member, so hidden-API enforcement makes
it unreachable from the helper process.

Fixes #1254
@thymikee thymikee force-pushed the claude/agent-device-1254-210583 branch from f0c398a to c2ff848 Compare July 15, 2026 11:05
@thymikee

Copy link
Copy Markdown
Member Author

Good catch — you're right that the no-arg reflection was a dead path on API 33. I went to implement the connection-scoped signature you described, but while verifying it on-device I hit a wall that changes the approach, so let me lay out what I found.

The reflective path is blocked by hidden-API enforcement

I built the clearCache(int connectionId) variant (connection id from the hidden UiAutomation.getConnectionId(), no-arg retained for pre-13) and instrumented it to report exactly what happens. Running the helper via am instrument on a real device (API 36):

# default (hidden-API enforced, as the daemon launches it):
dbgCacheClear=connid=null
dbgCacheClearError=java.lang.NoSuchMethodException: android.view.accessibility.AccessibilityInteractionClient.clearCache []

# same build, but: am instrument --no-hidden-api-checks
dbgCacheClear=reflect-connid ok connid=2
dbgCacheClearError=

Both UiAutomation.getConnectionId() and AccessibilityInteractionClient.clearCache(int) are blocklisted non-SDK members (@UnsupportedAppUsage(maxTargetSdk = S) on the latter, and our helper targets SDK 36). With enforcement on they're simply invisible to reflection — getConnectionId() returns null and getMethod("clearCache", …) throws NoSuchMethodException. So the reflective path only works with am instrument --no-hidden-api-checks, and that flag is rejected by am on API < 28 (where it's also unnecessary, since enforcement didn't exist yet). Threading an API-gated flag through the host to reach a blocklisted API felt like the wrong direction.

Switched to a public API that does the same thing

You noted setServiceInfo() calls clearCache() internally — that turns out to be the clean way out. UiAutomation.setServiceInfo(getServiceInfo()) is a public SDK call, and in AOSP it calls AccessibilityInteractionClient.getInstance().clearCache() before forwarding the (unchanged) config. No reflection, no hidden-API, no am flag. Verified in source across the whole pre-34 range:

  • API 24 · API 30 · API 33 — all call AccessibilityInteractionClient.getInstance().clearCache() inside setServiceInfo().

So the fix is now: UiAutomation.clearCache() on API 34+, setServiceInfo(getServiceInfo()) below that.

Verification (real device, API 36 — Nav3 repro, Home → Screen 2 → Screen 3)

Both branches exercised by forcing each in turn:

public clearCache() (API 34+ path):        run 1/2/3 PASS (Screen 3)
setServiceInfo() flush (pre-34 fallback):  run 1/2/3 PASS (Screen 3)

On the setServiceInfo path I couldn't spin up a physical API 33 emulator here (this SDK has no cmdline-tools/avdmanager to fetch the image), so that specific level rests on the AOSP source above plus the real-device run of the identical code path on 36. Happy to run it on a real API 33 device if you'd like belt-and-suspenders before merge.

Also rebased onto latest main. The clear still runs once before both traversal paths, unchanged.

@thymikee

Copy link
Copy Markdown
Member Author

Validation limitation (pre-34 real device)

Recording this so it's explicit for review: I could not exercise the pre-34 setServiceInfo() fallback on a physically pre-34 device.

  • No local API < 34 image (this environment's SDK has no cmdline-tools/avdmanager to fetch one, and there's no disk headroom for it).
  • AWS Device Farm can't cover it either: agent-device's Device Farm provider drives a hosted Appium/WebDriver session and takes snapshots via getPageSource (src/cloud-webdriver/webdriver-source.ts) — it never installs or runs the instrumentation helper this PR patches, and DF exposes no raw ADB (remote debugging is deprecated). So a DF run would validate Appium's snapshot backend, not this code.

What the fallback path does rest on:

  • AOSP sourceUiAutomation.setServiceInfo() calls AccessibilityInteractionClient.getInstance().clearCache() on API 24, API 30, and API 33.
  • Real-device run of the identical code path — forcing the setServiceInfo() branch on API 36 fixed the Nav3 staleness 3/3 (same as the public clearCache() branch, 3/3).

Given the fallback is a public, long-stable SDK call whose cache-flush is visible directly in source across the whole supported range, and the mechanism is exercised on real hardware, the residual risk is low. Flagging it rather than blocking on hardware we don't have.

@thymikee thymikee merged commit cc1a0ec into main Jul 15, 2026
22 checks passed
@thymikee thymikee deleted the claude/agent-device-1254-210583 branch July 15, 2026 11:59
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-15 11:59 UTC

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.

Android snapshot stays stale after the second Navigation 3 navigation (in-ComposeView content swap)

1 participant