Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .fallowrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"src/remote-config.ts",
"src/install-source.ts",
"src/android-apps.ts",
"src/android-snapshot-helper.ts",
"src/contracts.ts",
"src/selectors.ts",
"src/finders.ts",
Expand Down
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
android-snapshot-helper/debug.keystore binary
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,39 @@ jobs:
- name: Run typecheck
run: pnpm typecheck

android-snapshot-helper:
name: Android Snapshot Helper Package
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup toolchain
uses: ./.github/actions/setup-node-pnpm

- name: Install Android SDK packages
run: |
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$SDKMANAGER" ]; then
SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager"
fi
if [ ! -x "$SDKMANAGER" ]; then
echo "sdkmanager not found under $SDK_ROOT" >&2
exit 1
fi
yes | "$SDKMANAGER" --licenses >/dev/null
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"

- name: Check Java toolchain
run: |
javac --version
java --version

- name: Package npm-bundled Android snapshot helper
run: pnpm package:android-snapshot-helper:npm

integration-smoke:
name: Integration Smoke
runs-on: macos-26
Expand Down
94 changes: 94 additions & 0 deletions .github/workflows/release-android-snapshot-helper.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Release Android Snapshot Helper

on:
release:
types:
- published
workflow_dispatch:
inputs:
release_tag:
description: GitHub Release tag to upload assets to, for example v0.13.3.
required: true
type: string
checkout_ref:
description: Optional branch, tag, or commit SHA to build. Defaults to the selected workflow ref.
required: false
type: string

permissions:
contents: write

jobs:
publish-android-snapshot-helper:
name: Publish Android Snapshot Helper
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.inputs.checkout_ref || github.ref }}

- name: Setup Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "22"

- name: Install Android SDK packages
run: |
SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}"
SDKMANAGER="$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager"
if [ ! -x "$SDKMANAGER" ]; then
SDKMANAGER="$SDK_ROOT/cmdline-tools/bin/sdkmanager"
fi
if [ ! -x "$SDKMANAGER" ]; then
echo "sdkmanager not found under $SDK_ROOT" >&2
exit 1
fi
yes | "$SDKMANAGER" --licenses >/dev/null
"$SDKMANAGER" "platforms;android-36" "build-tools;36.0.0"

- name: Check Java toolchain
run: |
javac --version
java --version

- name: Resolve release metadata
id: meta
run: |
set -euo pipefail
PACKAGE_VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).version")"
TAG_NAME="${{ github.event.release.tag_name || github.event.inputs.release_tag }}"
VERSION="${TAG_NAME#v}"
if [ "$VERSION" != "$PACKAGE_VERSION" ]; then
echo "Release tag $TAG_NAME does not match package.json version $PACKAGE_VERSION" >&2
exit 1
fi
echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
shell: bash

- name: Package Android snapshot helper
id: package
env:
RELEASE_ASSET_DIR: ${{ github.workspace }}/.tmp/release-assets
run: |
set -euo pipefail
mkdir -p "${RELEASE_ASSET_DIR}"
sh ./scripts/package-android-snapshot-helper.sh \
"${{ steps.meta.outputs.version }}" \
"${{ steps.meta.outputs.tag }}" \
"${RELEASE_ASSET_DIR}"
shell: bash

- name: Upload helper assets to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload "${{ steps.meta.outputs.tag }}" \
"${{ steps.package.outputs.apk_path }}" \
"${{ steps.package.outputs.checksum_path }}" \
"${{ steps.package.outputs.manifest_path }}" \
--clobber
shell: bash
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ node_modules/
.pnpm-store/
.fallow/
dist/
.tmp/
.DS_Store
__pycache__/
*.pyc
Expand All @@ -23,9 +24,12 @@ xcuserdata/
*.xcsettings
*.xcresult
*.ipa
*.apk
*.dSYM
*.dSYM.zip
*.app
*.xctestrun
*.xcarchive
.skillgym-results/
android-snapshot-helper/build/
android-snapshot-helper/dist/
16 changes: 16 additions & 0 deletions android-snapshot-helper/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
package="com.callstack.agentdevice.snapshothelper">
<uses-sdk android:minSdkVersion="23" android:targetSdkVersion="36" />

<application
android:debuggable="true"
android:label="Agent Device Snapshot Helper"
android:theme="@android:style/Theme.NoDisplay" />

<instrumentation
android:name=".SnapshotInstrumentation"
android:targetPackage="com.callstack.agentdevice.snapshothelper"
android:label="Agent Device Snapshot Helper"
android:functionalTest="true" />
</manifest>
75 changes: 75 additions & 0 deletions android-snapshot-helper/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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

```sh
sh ./scripts/build-android-snapshot-helper.sh 0.13.3 .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

```sh
adb install -r -t .tmp/android-snapshot-helper/agent-device-android-snapshot-helper-0.13.3.apk
adb shell am instrument -w \
-e waitForIdleTimeoutMs 500 \
-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 debuggable instrumentation/test APK.
Devices or providers that block test-package installs must allow this package before helper capture
can run.

## Output Contract

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

Each XML chunk is sent with:

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

The final instrumentation result includes:

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

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.
Binary file added android-snapshot-helper/debug.keystore
Binary file not shown.
Loading
Loading