build-android-helper.sh compiled with whichever build-tools directory was newest on the image while every lane that builds a helper installs exactly build-tools;36.0.0. That selection feeds d8 and aapt2, so a newer package on the runner changed the helper's bytecode and resources rather than just its packaging, and nothing said so. The script now takes the version as an input, its last positional or AGENT_DEVICE_ANDROID_BUILD_TOOLS, resolves it under $SDK_ROOT/build-tools, and checks the four tools the build actually runs instead of aapt2 alone. An unpinned build is a hard failure on CI; locally the newest-installed fallback stays and is announced on stderr. Each lane that builds a helper declares the version it installs and interpolates that same value into its sdkmanager line and the build environment, so an install and a build inside one lane cannot drift. setup-android-replay-host exports it to the packaging gate and names it in both helper cache keys, which are keyed on APK bytes; size.yml and release-android-snapshot-helper.yml declare it at job level. The fixture-repack cluster keeps its own pin: a different command family that ships no helper APK. Closes #2527
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 it compiles with the
build-tools version named by AGENT_DEVICE_ANDROID_BUILD_TOOLS (or the script's last positional).
CI must name that version; a local build without it uses the newest version under build-tools
and says so on stderr.
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) ortransform(two pointers, e.g. pinch/rotate)durationMs: integer,0-120000pointers: array of{ pointerId, samples },pointerIds ordered from0, one pointer forswipeand exactly two fortransform- each pointer's
samplesis{ offsetMs, x, y }[]with at least two entries;offsetMsvalues must be strictly increasing (equal offsets are only allowed whendurationMsis0), the first sample'soffsetMsmust be0, and the last must equaldurationMs. Fortransformgestures, both pointers must share the sameoffsetMssequence.
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 modeviewport <requestId>— return interactive-window viewport boundsgesture <requestId> <payloadBase64>— inject a planned touch gesture (same payload as the one-shotgesturemode)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 acquisition facts decoded by the host, including
visible-to-user, drawing-order, bounds, text/description/id, interaction booleans, and window
metadata on window roots. The helper emits drawing-order on Android API 24+ and omits it on API
23, where the platform API is unavailable. The host keeps that fact as private capture evidence;
the daemon uses it to annotate covered actions without adding it to normalized snapshot nodes.
Each XML chunk is sent with:
outputFormat=uiautomator-xmlchunkIndexchunkCountpayloadBase64
The final instrumentation result for the default snapshot mode includes:
ok=truehelperApiVersion=2waitForIdleTimeoutMswaitForIdleQuietMstimeoutMsmaxDepthmaxNodesrootPresentcaptureMode(interactive-windowsoractive-window)windowCountnodeCounttruncatedelapsedMs
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.