Help us improve
Share bugs, ideas, or general feedback.
From simiancraft-skills
Headless Android emulator with Maestro-driven UI testing on Linux/WSL. Boots AVD, installs APK, drives UI via Maestro, captures logcat and screenshots.
npx claudepluginhub simiancraft/simiancraft-skills --plugin simiancraft-skillsHow this skill is triggered — by the user, by Claude, or both
Slash command
/simiancraft-skills:android-emulator-harnessThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Specializations (e.g. camera/mask) sit ON TOP of this kernel and override only the
Guides technical evaluation of code review feedback: read fully, restate for understanding, verify against codebase, respond with reasoning or pushback before implementing.
Share bugs, ideas, or general feedback.
Specializations (e.g. camera/mask) sit ON TOP of this kernel and override only the parts they must.
Runtime/package manager. Examples use
bun/bunx; substitute your own runner (npm/npx,pnpm/pnpm dlx, oryarn) wherever they appear. The Android tooling itself (adb,emulator, Maestro, the JDK) is unaffected.
/dev/kvm; the user must be in the
kvm group: sudo gpasswd -a $USER kvm (needs a real terminal for the password).
No relogin if you launch under sg kvm -c "...". Symptom if missing:
x86_64 emulation currently requires hardware acceleration!.java for sdkmanager/avdmanager/Maestro. Maestro
2.x aborts on Java 8 with ERROR: Java 17 or higher is required; it reads the
java on PATH, so exporting JAVA_HOME alone is not enough; prepend it to PATH.
Symptom: bare ~/.maestro/bin/maestro --version fails until you do.eas isn't on PATH, install eas-cli and use it
directly, or use the Expo MCP mcp__expo-mcp__* tools if your host provides them./dev/dri absent); on
native Linux you may have one. Either way the default software GLES is fine for
UI/logic; only GPU-compute workloads (e.g. MediaPipe) need the swangle override,
which the specialized camera skill covers.emulator or the system java. This one block gets you to an operating state:
# ANDROID_HOME varies by OS: Linux (Android Studio default) $HOME/Android/Sdk, macOS $HOME/Library/Android/sdk
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Android/Sdk}"
# JDK 17 home is OS/distro-specific; point this at wherever your JDK 17 lives.
# Debian/Ubuntu: /usr/lib/jvm/java-17-openjdk-amd64 macOS: $(/usr/libexec/java_home -v 17)
export JAVA_HOME="${JAVA_HOME:-/usr/lib/jvm/java-17-openjdk-amd64}"
export PATH="$JAVA_HOME/bin:$PATH" # so Maestro's wrapper sees Java 17
SDK="$ANDROID_HOME"; EMU="$SDK/emulator/emulator"; ADB="$SDK/platform-tools/adb"
# modern SDKs install to cmdline-tools/latest/bin; older/hand-installed to cmdline-tools/tools/bin
CLT="$(ls -d "$SDK"/cmdline-tools/latest/bin 2>/dev/null || ls -d "$SDK"/cmdline-tools/*/bin 2>/dev/null | head -1)"
SDKMGR="$CLT/sdkmanager"; AVDMGR="$CLT/avdmanager"
MAESTRO="$HOME/.maestro/bin/maestro"
# preflight: java -version → 17; $ADB version; $MAESTRO --version → 2.x
Default to x86_64 (faster under KVM, widest native-lib coverage). Use 32-bit
x86 ONLY when a required native lib lacks an x86_64 variant (see
android-emulator-mask-testing).
yes | "$SDKMGR" "system-images;android-34;google_apis;x86_64" "platforms;android-34"
# device profile must exist in this SDK's catalog; pixel_3 is safe on older SDKs, pixel_6 on newer
echo no | "$AVDMGR" create avd -n harness -k "system-images;android-34;google_apis;x86_64" -d pixel_6 --force
sg kvm -c "nohup $EMU -avd harness \
-no-window -no-audio -no-boot-anim -no-snapshot \
-gpu swiftshader_indirect \
-accel on -port 5554 > /tmp/emulator.log 2>&1 &"
$ADB wait-for-device
for i in $(seq 1 48); do
[ "$($ADB shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ] && break; sleep 5
done
$ADB shell settings put global window_animation_scale 0 # determinism: kill animations
$ADB shell settings put global transition_animation_scale 0
$ADB shell settings put global animator_duration_scale 0
Snapshot the warmed device once for fast restarts: launch without -no-snapshot,
let it boot, then future runs reuse the snapshot instead of cold-booting.
APKs come from: a local ./android/gradlew assembleDebug|Release, or an EAS
artifact (eas build:run -p android --latest downloads AND installs to the running
emulator; eas build:download --build-id <id> fetches only; the Expo MCP
mcp__expo-mcp__build_list works too). Inspect a build's git commit
(eas build:list --json) BEFORE diagnosing; a stale binary vs fresh JS is a common
false bug.
AAPT2="$(ls "$SDK"/build-tools/*/aapt2 2>/dev/null | sort -V | tail -1)" # newest installed build-tools
PKG=$("$AAPT2" dump badging app.apk | sed -n "s/package: name='\([^']*\)'.*/\1/p")
$ADB install -r -g app.apk # -g grants runtime perms (CAMERA, etc.) up front
$ADB shell monkey -p "$PKG" -c android.intent.category.LAUNCHER 1
Launch-type fork (check the landing activity):
$ADB shell dumpsys activity activities | grep topResumedActivity
.MainActivity → standalone, JS embedded. Done.…DevLauncherActivity → an Expo dev build; needs Metro. Start it
(bunx expo start in the app dir), adb reverse tcp:8081 tcp:8081, then open the
dev-client launch URL. The URL is your app's own custom scheme with the
expo-development-client host (not literally expo-development-client://, and not a
bare Metro URL):
adb shell am start -a android.intent.action.VIEW -d "<your-app-scheme>://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8081"
(with adb reverse in place use localhost; without it, url-encode http://10.0.2.2:8081).
Prefer a preview/standalone build for unattended runs to avoid this entirely.Auth/login. Many apps gate the first screen behind login. The first flow must
authenticate from env or an out-of-repo secrets file (NEVER hardcode). Pass secrets to
Maestro with --env KEY=VALUE (or an env file) and read them in the flow as ${KEY};
drive the login like any other screen and keep creds outside the repo.
Maestro ($MAESTRO, i.e. ~/.maestro/bin/maestro) is the resilient driver: selects
by text/id, waits for elements, retries, screenshots; it is app-agnostic and tests the
final bundled binary (no Detox/Appium npm shim inside the app). Install once:
curl -Ls "https://get.maestro.mobile.dev" | bash.
Selector best practice for React Native: testID. A testID prop on a component
is what Maestro matches via id:, and it's stable across copy changes, localization,
and re-layout, unlike visible text. Add testID="preview" in the app, select with
{ id: "preview" }. Visible-text taps (tapOn: "Dark Office") are fine for quick
smoke flows but brittle as a contract. If you can edit the app, prefer testID.
A flow is YAML (appId header, then steps):
# flows/smoke.yaml
appId: com.example.app
---
- launchApp
- assertVisible: "Sign in" # or your first screen
- tapOn: { id: "preview-toggle" } # by testID, stable
- tapOn: "Dark Office" # by visible label, quick but brittle
- assertVisible: { id: "preview" } # waits, retries
- takeScreenshot: dark-office
Run it (Maestro auto-targets the connected adb device):
$MAESTRO test flows/smoke.yaml --format junit --output /tmp/maestro-report.xml
ls ~/.maestro/tests/*/ # screenshots land here
Finding selectors: $MAESTRO hierarchy prints the live view tree (text + resolved
ids); use it to discover what to tap before writing the flow. $MAESTRO studio is the
interactive picker. Both beat guessing.
Driving a slider (Android SeekBar). RN sliders back onto a native
android.widget.SeekBar, which tapOn selects but can't set a value. Three steps:
"bounds" from $MAESTRO hierarchy
(the "bounds" that immediately precedes "class" : "android.widget.SeekBar").
Tapping the slider's label y can miss the track and just scroll the parent; use the
widget's own bounds, not the label's.adb shell input swipe <x_from> <y> <x_to> <y> 500
(or input tap <x> <y>), with x = x0 + value*(x1-x0) across the track bounds.Expo Go caveat: you cannot launchApp a custom appId in Expo Go; use
openLink: exp://10.0.2.2:8081 instead (the host-loopback alias from inside the
emulator; 127.0.0.1 only reaches host Metro after adb reverse tcp:8081 tcp:8081).
A preview/standalone or dev build takes plain launchApp.
Last-resort fallback when Maestro genuinely can't see a custom-rendered (e.g. GL)
element: uiautomator dump + parse bounds + adb shell input tap <cx> <cy>.
Brittle; use only when hierarchy shows nothing tappable.
adb logcat -c), act,
then adb logcat -d | grep -iE "FATAL EXCEPTION|ANR in|refcount < 1|UnsatisfiedLink|<your app's error tags>"
must be empty. Also assert expected init lines ARE present.adb exec-out screencap -p > shot.png, then
Read it. Don't pixel-diff animated/GPU content; judge structurally + by eye.$ADB -s emulator-5554 emu kill
-gpu swangle_indirect +
imagefile: camera feed). Overrides the AVD image, the boot/camera command, and
adds mask assertions.Verified local command output on the INSTALLED tooling (-help-*,
dumpsys SurfaceFlinger, logcat, screenshots) OUTRANKS docs, which describe
whatever version Google currently ships. Re-check on each new machine.