Files
Michał Pierzchała 84e6f9bf2d refactor(android): raw is the acquired tree; one presentation for dialog recovery; residues declared (#1832 C3) (#1865)
* refactor(android): raw is the acquired tree; one presentation for dialog recovery; residues declared (#1832 C3)

- C3: the three regular-projection pruners (invisible subtrees, stale application windows,
  covered same-window surfaces) move out of parseUiHierarchyTree into the projection as a
  non-mutating classification (collectAndroidHiddenNodes in ui-hierarchy-visibility.ts). --raw
  presents the acquired tree; interactive ⊆ regular ⊆ raw by construction. Hidden-content hints
  and the scope root are derived per projection over retained children, which is what the
  mutating pruners implied. Property-checked identical to main for regular/-i/depth/scope over
  12,000 random tree × projection pairs; raw grew on 2,514/3,000 and never shrank.
- Android blocking-dialog recovery routes through buildSnapshotState (the one presentation), which
  moves to src/daemon/snapshot-state.ts below the daemon-server type cycle; importers repointed,
  its tests mirror the module.
- Freshness route signature drops role/selected (Android never carries them).
- Residues declared at their sites and in CONTEXT.md; docs + CHANGELOG.
- ui-hierarchy.ts split by question: node predicates (ui-hierarchy-node.ts), regular-projection
  visibility (ui-hierarchy-visibility.ts), scope (ui-hierarchy-scope.ts); 974 → 634 LOC.

* test: lower the snapshot.test.ts size pin to its new length

* fix(android): dialog recovery acts on the presentation's occlusion result

Review P1 on #1865: routing blocking-dialog recovery through buildSnapshotState made the occlusion
result available but nothing consumed it. containsBlockingDialog scanned every node and
findCloseAppButton returned the first text match with a rect, so a stale ANR surface left under the
foreground one could still trigger recovery, and a covered "Close app" could be tapped ahead of the
visible top button — the disagreement the routing was supposed to remove.

Both decisions now filter through isSnapshotNodeInteractionBlocked, the shared predicate over the
annotator's structured result. Two regressions cover it, both proven red against an unfiltered
selection: a covered Close app preceding a visible one (asserts the visible center is tapped) and a
fully covered dialog signal (asserts recovery does not trigger, no tap dispatched).

Rebase reconciliation: screenshot-runtime.ts arrived on main (#1878) importing buildSnapshotState
from its old home; repointed to src/daemon/snapshot-state.ts with the other importers.
2026-08-19 18:17:45 +02:00
..

Android Snapshot Helper

Small instrumentation APK used to capture Android accessibility snapshots without relying on uiautomator dump's fixed idle wait behavior. The helper enables Android's interactive-window retrieval flag and serializes every accessible window root returned by UiAutomation.getWindows() so keyboards and system overlays can appear in the same snapshot. If interactive window roots are unavailable, it falls back to the active-window root.

The helper is intentionally provider-neutral. Local adb, cloud ADB tunnels, and remote device providers can all install and run the same APK as long as they can execute ADB-style operations. Released helper APKs use the committed debug.keystore; do not rotate it casually, because Android requires a stable signing certificate for adb install -r upgrades.

Build

VERSION="$(node -p 'require("./package.json").version')"
AGENT_DEVICE_ANDROID_HELPER=snapshot sh ./scripts/build-android-helper.sh "$VERSION" .tmp/android-snapshot-helper

The build uses Android SDK command-line tools directly. It expects ANDROID_HOME or ANDROID_SDK_ROOT to point at an SDK with platforms/android-36 and matching build tools. pnpm prepack builds the npm-bundled helper into android/snapshot-helper/dist; npm users get that APK in the package and the first helper-backed snapshot installs it automatically when missing or outdated.

Run

VERSION="$(node -p 'require("./package.json").version')"
adb install -r -t ".tmp/android-snapshot-helper/agent-device-android-snapshot-helper-$VERSION.apk"
adb shell am instrument -w \
  -e waitForIdleTimeoutMs 500 \
  -e waitForIdleQuietMs 100 \
  -e timeoutMs 8000 \
  -e maxDepth 128 \
  -e maxNodes 5000 \
  com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation

maxDepth also caps recursive traversal depth inside the helper. The -t install flag is required because the helper is a test-only instrumentation APK. Devices or providers that block test-package installs must allow this package before helper capture can run.

waitForIdleTimeoutMs defaults to 500, which is a maximum wait, not a fixed sleep. Direct helper invocations can pass 0 when immediate capture during ongoing animation is preferred. Root acquisition has a separate 500 ms stabilization bound that is used only when no root is available or an active/focused window is temporarily missing its root; complete captures pay no additional wait.

One-Shot Modes

Passing -e mode snapshot|viewport|gesture selects what a single instrumentation run does; snapshot is the default and matches the Run section above.

# Read the interactive-window viewport without capturing a snapshot.
adb shell am instrument -w -e mode viewport \
  com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation

# Inject a planned touch gesture, described by a base64 JSON payload.
adb shell am instrument -w -e mode gesture -e payloadBase64 "$PAYLOAD" \
  com.callstack.agentdevice.snapshothelper/.SnapshotInstrumentation

The gesture payload is a base64-encoded JSON object using protocol android-touch-plan-v1:

  • kind: swipe (one pointer) or transform (two pointers, e.g. pinch/rotate)
  • durationMs: integer, 0-120000
  • pointers: array of { pointerId, samples }, pointerIds ordered from 0, one pointer for swipe and exactly two for transform
  • each pointer's samples is { offsetMs, x, y }[] with at least two entries; offsetMs values must be strictly increasing (equal offsets are only allowed when durationMs is 0), the first sample's offsetMs must be 0, and the last must equal durationMs. For transform gestures, both pointers must share the same offsetMs sequence.

Persistent Session

Passing -e sessionPort <port> keeps the instrumentation alive after startup and serves repeated commands over a local TCP server on 127.0.0.1:<port>, instead of exiting after one snapshot. This avoids paying UiAutomation connect/teardown cost per call. Each command uses one short-lived connection: the client connects, sends a single command line, reads the response, and the server closes that connection; the process stays alive for the next connection:

  • snapshot <requestId> — capture and return an XML snapshot, same semantics as the default mode
  • viewport <requestId> — return interactive-window viewport bounds
  • gesture <requestId> <payloadBase64> — inject a planned touch gesture (same payload as the one-shot gesture mode)
  • quit <requestId> — acknowledge and stop the session

viewport and gesture responses are headers-only (no body): kind, injectedEvents, elapsedMs for gesture, and x, y, width, height for viewport. snapshot responses carry the XML body after the header block, as described below. The response protocol literal is always android-snapshot-helper-v1, regardless of session or one-shot transport.

Output Contract

The APK emits instrumentation status records using agentDeviceProtocol=android-snapshot-helper-v1.

The XML node attributes intentionally mirror fields consumed by the host parser, including visible-to-user, drawing-order, bounds, text/description/id, interaction booleans, and window metadata on window roots. drawing-order lets the host suppress covered same-window surfaces that the helper traversal can receive even when they are not user-reachable. The helper emits drawing-order on Android API 24+ and omits it on API 23, where the platform API is unavailable.

Each XML chunk is sent with:

  • outputFormat=uiautomator-xml
  • chunkIndex
  • chunkCount
  • payloadBase64

The final instrumentation result for the default snapshot mode includes:

  • ok=true
  • helperApiVersion=2
  • waitForIdleTimeoutMs
  • waitForIdleQuietMs
  • timeoutMs
  • maxDepth
  • maxNodes
  • rootPresent
  • captureMode (interactive-windows or active-window)
  • windowCount
  • nodeCount
  • truncated
  • elapsedMs

viewport and gesture one-shot results carry agentDeviceProtocol/helperApiVersion/ outputFormat plus the mode-specific fields described under "One-Shot Modes" above, instead of the snapshot-mode fields listed here.

Failures return ok=false, errorType, and message in the final result.

The release manifest is a stable provider contract for the current helper protocol. Providers should resolve the APK from apkUrl, verify sha256, install using installArgs, and run instrumentationRunner. installArgs must start with install; extra arguments are limited to the allowlisted adb install flags -r, -t, -d, and -g, and the consumer appends the APK path.