Android Emulator Skill
Build, test, and automate Android applications using accessibility-driven navigation and structured data instead of pixel coordinates.
Invoking these scripts
Scripts live in scripts/, beside this file. Use a path rooted at the skill
directory, not a bare relative path — when the skill is installed as a plugin
and you are working in a project, python scripts/foo.py is a file-not-found.
SKILL_DIR=/path/to/skills/android-emulator-skill
python3 "$SKILL_DIR/scripts/screen_mapper.py" --json
The examples below are written with $SKILL_DIR set that way.
Quick Start
# 1. Launch app
python3 "$SKILL_DIR/scripts/app_launcher.py" --launch com.example.app
# 2. Map screen to see elements
python3 "$SKILL_DIR/scripts/screen_mapper.py"
# 3. Tap button (add --scroll-to-find if it may be below the fold)
python3 "$SKILL_DIR/scripts/navigator.py" --find-text "Login" --tap
# 4. Enter text
python3 "$SKILL_DIR/scripts/navigator.py" --find-type EditText --enter-text "user@example.com"
# 5. Run accessibility audit
python3 "$SKILL_DIR/scripts/accessibility_audit.py"
All scripts support --help for detailed options and --json for machine-readable output.
Scripts (v0.7.0)
Implemented (32 scripts)
Core Utilities (11 modules in common/)
common/device_utils.py - ADB command building and device detection
common/screenshot_utils.py - Screenshot capture and processing
common/cache_utils.py - Progressive disclosure cache system
Also in
common/: adb_exec.py (the one bounded entry point for every adb call, with typed errors that name a remedy), hierarchy.py (the one way to capture the UI hierarchy — no temp files, so concurrent runs cannot read each other's screen), emu_console.py (adb emu, which exits 0 even when it fails), logcat.py (the one place that builds anadb logcatargv and parses a duration, shared by all four log readers), sdk_tools.py (resolves SDK binaries;emulatorby bare name hits the<sdk>/emulatordirectory on an SDK-root PATH and raisesPermissionError), env_config.py, anr_pipeline.py, anr_sessions.py.
App Management (1 script)
- app_launcher.py - App lifecycle management
- Launch apps by package name.
--launchwaits for the activity to be displayed (am start -W) and fails if the system reports anything butStatus: ok, so the next command maps the app's screen rather than whatever was still in front. The message reports the activity that came up and how long it took. - Terminate apps
- Install/uninstall APKs
- Deep link navigation.
--open-urlwaits the same way--launchdoes and fails when the intent resolves to nothing, rather than reporting a URL it merely handed to the system. - List installed packages
- Check app state
- Options:
--launch,--terminate,--install,--uninstall,--open-url,--list,--state,--json
- Launch apps by package name.
Device Lifecycle (5 scripts) ✓ COMPLETE
emulator_boot.py - Boot emulators with optional readiness verification
- Boot by AVD name
- Wait for device ready with timeout
- Batch boot operations
- Headless mode support
--list-avdsreports an empty list (No AVDs found, or{"avds": []}) and exits 0 only when the emulator ran and this host defines no AVDs. A missing or failingemulatorbinary is an error naming where it was looked for, and exits 1 ({"error": ...}under--json) — "no AVDs" and "could not look" are different answers.--allreports the same failure the same way- Refuses to boot while any attached emulator cannot say which AVD it is
(a non-
devicestate, or a console that will not answer), or while the emulators cannot be listed at all: it may be this AVD, and a second instance of one AVD corrupts its userdata. The refusal names the serial, its state, and says to terminate the stale emulator process — a console that is not answering cannot be shut down through one - Failures answer in the mode they were asked in:
{"error": ...}under--json, the message on stderr otherwise, always non-zero - Options:
--avd,--wait-ready,--timeout,--headless,--list-avds,--json
emulator_shutdown.py - Gracefully shutdown emulators
- Shutdown by serial number. Emulators only: a serial that is not an
emulator-NNNNis refused before any adb command runs, so this never powers off an attached phone or tablet --name AVDresolves the AVD to a serial. If some emulator could not be queried — or the emulators could not be listed at all — that is reported as itself and exits non-zero, never as "No running emulator found", which is a confident negative over a device nobody managed to ask- Failures answer in the mode they were asked in:
{"error": ...}under--json, the message on stderr otherwise, always non-zero - Optional verification of shutdown completion
- Batch shutdown operations (running emulators only)
- Options:
--serial,--name,--verify,--timeout,--all,--json
- Shutdown by serial number. Emulators only: a serial that is not an
emulator_create.py ⭐ NEW - Create AVDs dynamically
- Create by device type and API level
- List available device definitions
- List available system images
- Options:
--device,--api,--name,--abi,--variant,--list-devices,--list-images,--json
emulator_delete.py ⭐ NEW - Delete AVDs permanently
- Delete by AVD name, or in batch with
--all/--old N(--yesskips the prompt) - List available AVDs
- Every mode reports a missing or failing
avdmanagerthe same way: exit 1 with thecmdline-toolsremedy, never "No AVDs deleted" at exit 0 --old Nranks by the mtime of<name>.avd; an AVD whose directory cannot be found fails the whole ranking (exit 1) rather than sorting oldest and being deleted. The AVD home is$ANDROID_AVD_HOME, else$ANDROID_SDK_HOME/.android/avd, else~/.android/avdNmust be at least 1:--old 0keeps nothing, so it is rejected as a usage error (exit 2) pointing at--all --yes- Failures answer
{"error": ...}under--json; a batch keeps its summary and carrieserroralongside it when any AVD failed - Options:
--name,--all,--old,--yes,--list,--json,--verbose
- Delete by AVD name, or in batch with
emulator_erase.py ⭐ NEW - Factory reset AVDs
- Wipe user data without deleting AVD
- Preserve AVD configuration
- Batch erase with
--all;--verifypolls the AVD on disk until the wipe lands, bounded by--timeout(default 90s,ANDROID_EMU_ERASE_TIMEOUT) - Refuses to erase unless every attached emulator has been identified and
none of them is this AVD. A console query that failed, or an emulator not
yet in state
device, is "unknown" and refuses, naming the serial, its state and the remedy —--forceerases anyway - Every failure answers in the mode it was asked in:
{"error": ...}on stdout under--json, the message on stderr otherwise, always non-zero.--allkeeps its per-AVD summary and carrieserroralongside it when any AVD failed - Snapshots are kept: a successful erase prints "snapshots kept; use
snapshot.py --delete
<name>to remove them", because a latersnapshot.py --loadcan otherwise undo the factory state - Options:
--name,--all,--force,--verify,--timeout,--list,--json,--verbose
Build & Development (2 scripts) ✓ COMPLETE
build_and_test.py - Gradle build/test automation with progressive disclosure (backed by the
gradle/subpackage)- One-line summary + a result ID by default; drill in on demand
- Parses JUnit XML for pass/fail counts + failed test names; extracts Gradle/Kotlin/Java errors & warnings
- Options:
--project,--module/-p,--variant,--clean,--test,--suite,--get-errors/--get-warnings/--get-log <ID>,--list-builds,--verbose,--json
log_monitor.py ⭐ NEW - Real-time logcat monitoring
- Filter by app package
- Filter by severity (error/warning/info/debug)
- Smart deduplication
- Duration-based or follow mode.
--duration/--lastneed a unit (30s,5m,1h); a bare number is rejected as a usage error (exit 2) - Save logs to file
- Options:
--app,--serial,--severity,--follow,--duration,--output,--clear,--verbose,--json
Navigation & Interaction (4 scripts)
screen_mapper.py - Analyze current screen and list interactive elements
- Listing is the default, not a mode: there is no
--list. The bare command prints the summary;--verboseexpands it to the per-element breakdown,--hintsadds navigation suggestions. - The default output names every interactive control, one line per kind
(
Button:,Control:,CheckBox:,EditText:…), capped per kind. Those names are exactly whatnavigator.py --find-textaccepts, so step 2 of Quick Start feeds step 3 directly. It used to print counts only, and on a Compose screen the names existed solely under--verbose/--json. - A control with no text of its own is named by the caption recovered from
its subtree or its row; one with a resource id is named by the bare id
(
com.android.settings:id/search_action_barprints assearch_action_bar), which is whatnavigatorprints and what--find-idtakes. Compose test tags are already bare, so both toolkits name things the same way. --hintsprints the next command to run, already filled in.- Options:
--serial/-s,--verbose/-v,--hints,--json
- Listing is the default, not a mode: there is no
navigator.py - Find and interact with elements semantically
- Find by text, type, resource ID
- Tap, enter text, get bounds
- Fuzzy matching support
--find-textmatches any name the screen report printed: an element's own text or content-desc, the caption recovered for an unlabelled control, or a resource id (bare or fully qualified, matched whole). A match on a caption resolves to the control that owns it -- the control it sits inside, or the one beside it in the same row -- so the tap lands on the checkbox rather than 143px to its right. The owner must be tappable (clickable / long-clickable / checkable): a scrolling container is interactive, but it is not what a caption inside it names, and resolving to it would tap the middle of the screen. A name that matches only a passive label with no such control is refused, not tapped.--tapand--enter-textrequire a target: one of--find-text,--find-exact,--find-type,--find-id, or explicit--tap-at x,y. Without one it is a usage error (exit 2) and nothing is sent to the device.--max-scrollsis capped at 50 (so isANDROID_EMU_MAX_SCROLLS), and a--scroll-to-findsearch is also bounded in wall-clock time (ANDROID_EMU_SCROLL_SEARCH_DEADLINE, default 120s -- see--help), which bounds the screen dumps too. Each scroll prints a progress line to stderr as it happens.- Under
--json, a successful--tap/--enter-textreportstapped_at: [x, y]-- the coordinates that reached the device, so a caller can check where the tap went without parsing prose. Every failure, including a usage error, prints{"error": ...}and exits non-zero. --scroll-to-findsearches below the fold. Without it a lookup sees only the visible screen, andNot foundis indistinguishable from "the item is two rows down". The default path now says which it was:(searched 1 screen; this screen scrolls -- retry with --scroll-to-find). Scrolling is opt-in on purpose -- a swipe is not a read, so a lookup that scrolled silently would let--find-text "Save is visible"pass for a button three screens away.- Options:
--find-text,--find-type,--find-id,--tap,--enter-text,--list,--scroll-to-find,--scroll-direction {down,up},--max-scrolls N,--serial,--json,--verbose
gesture.py - Perform swipes, scrolls, long press
--scroll {up,down}names the direction the content moves;--swipenames what the finger does. They are opposites. (Conflating them was a real defect:--scroll downdragged lists back toward the top and reported success.)- Directional swipes
- Custom swipe coordinates
- Scroll and long press
- Options:
--swipe,--from-edge,--duration,--long-press,--scroll,--serial,--json
keyboard.py - Text input and hardware buttons
- Type text with
--type. There is no--text— that flag belongs topush_notification.py, and guessing it here costs a turn.--delay Ntypes one character at a time for fields that debounce. - Press special keys (
--key enter, repeated with--count N) and hardware buttons (--button back);--keys a,bpresses a sequence.--keyand--buttonshare one name table, so either accepts either name. --clear Ndeletes N characters;--hide-keyboard/--dismissput the soft keyboard away. Both readdumpsys input_methodfirst and press BACK only when an IME is actually shown — BACK is not a "hide keyboard" key, and with no keyboard up it leaves the current screen. With none shown they report "No keyboard shown", send no key event and exit 0; if the IME state cannot be read they exit 1 rather than guess. To press BACK regardless, ask for it:--button back. (The state is read frommInputShown, withmIsInputViewShownas a backup — both measured on API 33 and API 35.)- Options:
--type,--delay,--key,--count,--keys,--button,--clear,--hide-keyboard,--dismiss,--serial/-s,--json
- Type text with
Testing & Analysis (4 scripts) ✓ COMPLETE
accessibility_audit.py ⭐ NEW - WCAG compliance checking
- Missing content descriptions: any operable control (by its uiautomator
properties, not its class name) with no describing text in itself or its
subtree is critical. The old class-name gate could not fire on a Compose
screen at all, where controls are plain
android.view.View. - Touch target size verification
- EditText hint checking
- Image accessibility
- Categorize by severity (critical/warning/info)
- Save reports (JSON + Markdown)
- Options:
--serial,--output,--verbose,--json
- Missing content descriptions: any operable control (by its uiautomator
properties, not its class name) with no describing text in itself or its
subtree is critical. The old class-name gate could not fire on a Compose
screen at all, where controls are plain
visual_diff.py - Compare screenshots for visual changes
- Pixel-perfect comparison
- Highlight differences
- Generate diff images
- Usage:
visual_diff.py BASELINE CURRENT [--output DIR] [--threshold N] [--json] - Note: the two images are positional.
--jsonnow emits the full report, so this script keeps the same contract as every other one;--detailssurvives as a deprecated alias for callers written before it. - Options:
--output,--threshold,--json,--details
test_recorder.py - Document a test run, step by step
- Session-based, one process per step — there is no
--test-name,--outputor--inline.--start NAMEopens a session and prints its id; each later--step "description"captures the current screen into it;--stopwritesreport.md+manifest.json.--stepand--stopdefault to the newest session, or target one with--session ID. - Each step records a screenshot and the UI hierarchy, plus optional
--screen,--stateand--assert(--assert-failedmarks it failed). - Sessions live under
~/.android-emulator-skill, not in an output directory you pass: retrieve with--list/--get-details ID, remove with--clear [--older-than 24h]. - Options:
--start,--step,--stop,--list,--get-details,--clear,--session,--serial,--app-name,--size,--screen,--state,--assert,--assert-failed,--failed,--older-than,--verbose,--json
- Session-based, one process per step — there is no
app_state_capture.py ⭐ NEW - Complete debugging snapshots
- Capture screenshot + UI hierarchy + logs + app info
- Create timestamped snapshots
- All-in-one debugging artifact
- Options:
--package,--output,--serial,--logs,--no-logs,--screenshot-size,--json
Advanced Testing & Permissions (3 scripts) ✓ COMPLETE
privacy_manager.py ⭐ NEW - App permission management
- Grant/revoke permissions
- List app permissions
- Support for 20+ permission types
- Batch operations
- Options:
--grant,--revoke,--list,--package,--serial,--list-permissions,--json
status_bar.py ⭐ NEW - Status bar control
- Set battery level and charging state
- Set WiFi/mobile signal strength
- Set time display (for consistent screenshots)
- Demo mode support
- Options:
--preset,--battery,--charging,--wifi,--mobile,--time,--reset,--serial,--json - All of these drive SystemUI demo mode, which changes what the status
bar draws. It does not change what the app reads from the system
(
BatteryManagerstill reports the real level).
push_notification.py - Post into the shade, and read back what is posted
- Rescoped in v0.6.0 to what adb can actually do. It no longer claims to
send an app's notifications:
--postposts viacmd notification post, and the result is owned by com.android.shell on channelshell_cmd, not by the app under test. The app's own channel, receiver and rendering are therefore not exercised. It is still the way to drive a NotificationListenerService, the shade UI, or an agent that reacts to a notification.--tagis the title and key,--textthe body. --listreads back what is really posted, by any package — the check that a notification exists rather than that a command exited 0.--expect-package PKGturns that read-back into an exit status.--grant-permission/--revoke-permissiontogglePOST_NOTIFICATIONS(API 33+) on--package, for exercising the runtime-permission path.- Options:
--post,--list,--grant-permission,--revoke-permission,--tag,--text,--no-verify,--expect-package,--package,--serial,--json,--verbose - ⚠️ It cannot deliver into an app's own FCM handler — no adb path reaches
that, because the c2dm receiver is protected by a permission held by
Play services, not by the shell user. Send through FCM instead. Channels
cannot be listed either:
cmd notificationhas nolist channelssubcommand, which is why the old--list-channelsnever worked.
- Rescoped in v0.6.0 to what adb can actually do. It no longer claims to
send an app's notifications:
Discovery & Device State (14 scripts) ⭐ NEW
android_health_check.sh - Verify the environment (ANDROID_HOME, adb, emulator, avdmanager, sdkmanager, java, Python 3.12+, Pillow); lists connected devices and AVDs. Exits non-zero if adb is missing.
device_list.py - List connected devices (
adb devices -l) and defined AVDs (emulator -list-avds) with progressive disclosure.- An empty inventory at exit 0 means the tools ran and found nothing. A
missing or failing
adboremulatorexits 1 with the remedy ({"error": ...}under--json).avdmanageris the exception: it only adds target/ABI detail, so its absence is a warning on stderr (and inwarningsin the JSON) rather than a failure - Options:
--get-details,--device-type/--name,--json
- An empty inventory at exit 0 means the tools ran and found nothing. A
missing or failing
emulator_selector.py - Suggest the best AVD (ranked by running → recently used → latest API → common models); list or boot one.
- A host with no AVDs ranks nothing and exits 0; a missing or failing
emulatorbinary exits 1 with the remedy instead of an empty ranking - An emulator that will not say which AVD it is gets no "currently running"
bonus, but is listed with its state (
unidentified_emulatorsunder--json, a warning on stderr otherwise) so the ranking never claims a completeness it does not have. If the emulators cannot be listed at all, the ranking still runs — the AVDs on disk are real — and says so (warningsunder--json) - Options:
--suggest,--list,--boot NAME,--headless,--count,--json,--verbose
- A host with no AVDs ranks nothing and exits 0; a missing or failing
localization_audit.py - Audit
res/values*/strings.xmlfor missing keys per locale and placeholder mismatches; optional source cross-reference.- Options:
--res DIR,--source DIR,--locale CODE,--strict,--json,--verbose
- Options:
appearance.py - Control dark/light mode (
cmd uimode night) and font scale (settings put system font_scale); best-effort locale.- Options:
--theme {light,dark},--text-size {small,default,large,xl},--font-scale,--locale,--reset,--serial,--json
- Options:
location.py - Simulate GPS on an emulator via
adb emu geo fix(fixed coords, city presets, GPX route replay). Emulator-only.- Options:
--lat/--lng,--city,--gpx FILE,--interval/--speed,--clear,--list-cities,--serial,--json
- Options:
container.py ⭐ NEW - Inspect a debuggable app's sandbox via
adb shell run-as(fails clearly on release apps).- List/read files, dump
shared_prefsXML, list databases and dump SQLite schema (Room == SQLite), export a snapshot - Options:
--package,--ls [SUBPATH],--cat FILE,--shared-prefs [NAME],--databases [NAME],--export DIR,--serial,--json
- List/read files, dump
model_inspector.py ⭐ NEW - Inspect Android persistence: Room annotations from source, Room exported-schema JSON, and live SQLite schema via
run-as(Room == SQLite).- Options:
--source DIR,--schema PATH,--show-versions,--raw NAME,--package,--db NAME,--serial,--json,--verbose
- Options:
anr_watcher.py ⭐ NEW - Record & summarise Android ANRs/jank from logcat (Choreographer skipped-frames + ActivityManager ANRs) with session-based progressive disclosure.
- Session mode:
--start [--package PKG]→ id;--stop ID→ token-tight summary;--get-details ID [--cluster N];--list-sessions;--clear-sessions;--diff A B - An unknown session, a session with no summary yet, or a
--clusterpast the end exits 1 and says what to run instead ({"error": ...}under--json) — it does not print the sentence and exit 0 --older-thantakes30s/5m/24h/7dand a session id isanr-YYYYMMDD-HHMMSS-XXXX; either malformed is a usage error (exit 2), and--clear-sessionsdeletes nothing when it rejects one- Legacy:
--watch [--duration N],--since 5m - Options:
--watch,--since,--start,--stop,--get-details,--list-sessions,--clear-sessions,--diff,--package,--serial,--duration,--min-frames,--top,--all,--budget-tokens,--cluster,--raw,--older-than,--terse,--json
- Session mode:
sms.py ⭐ NEW - Deliver an inbound SMS to an emulator and prove it arrived. Emulator-only.
--sendreads the inbox back and reportsacceptedanddeliveredseparately: the console'sOKmeans the command was taken, not that a message exists. Delivery is asynchronous (~2s measured), so it polls.--otpextracts a one-time code from the newest message and prints the message it came from, so the heuristic can be checked.- Options:
--send --to NUM --body TEXT [--no-verify],--list [--limit N],--otp,--serial,--json,--verbose
snapshot.py ⭐ NEW - Save/load/delete emulator snapshots (
adb emu avd snapshot). A ~2s state reset in place of a 60s reboot. Emulator-only.- A failed load is reported as a failure, which is the whole point:
adb emuexits 0 even when it answersKO, so a restore that did not happen would otherwise be reported as success and the next test would run against unknown state. - Options:
--list,--save NAME,--load NAME,--delete NAME,--no-verify,--timeout,--serial,--json,--verbose
- A failed load is reported as a failure, which is the whole point:
crash_triage.py ⭐ NEW - Parse the dedicated crash buffer (
logcat -b crash) into structured crashes, grouped by fault.- Groups repeats by package + exception + signature frame (a crash loop otherwise floods the output), and names the frame most useful for triage, labelled with the basis for the choice — including "no app frame; every frame is framework code" when that is the honest answer.
- Exit status answers "did triage run", not "did anything crash" — branch on
crash_countin--json, or pass--fail-on-crash. - Options:
--package PKG,--clear,--fail-on-crash,--serial,--json,--verbose
logs.py ⭐ NEW - One entry point for reading logs; routes on the question being asked.
logs.py tail(main buffers) →log_monitor.py;logs.py crashes(-b crash) →crash_triage.py;logs.py anr(ANR/jank, incl. session mode) →anr_watcher.py.- Arguments are passed through verbatim, so each verb takes the full flag set of the script it delegates to, and those scripts remain callable unchanged.
- Options:
<verb> [verb options],--json(routing table),--help
avd.py ⭐ NEW - One entry point for the emulator/AVD lifecycle; routes on the question being asked.
avd.py list→device_list.py;pick→emulator_selector.py;create→emulator_create.py;start→emulator_boot.py;stop→emulator_shutdown.py;reset(wipe data, keep the AVD) →emulator_erase.py;delete(remove the AVD) →emulator_delete.py.resetanddeleteare separate verbs because they destroy different things, and a near-miss between them is asked about rather than guessed.- Arguments are passed through verbatim, so each verb takes the full flag set of the script it delegates to — including its own confirmation flag (
delete --yes,reset --force) — and those scripts remain callable unchanged. - Options:
<verb> [verb options],--json(routing table),--help
The build system lives in the
gradle/subpackage (builder,results,cache,config,reporter), used bybuild_and_test.py. The ANR watcher's clustering/session machinery lives incommon/anr_pipeline.pyandcommon/anr_sessions.py.SDK binary resolution lives in
common/sdk_tools.py(get_emulator_path()). Never execemulatorby bare name: the SDK root contains a directory namedemulator, so a PATH holding$ANDROID_HOMEinstead of$ANDROID_HOME/emulatormakes execve raisePermissionError— not theFileNotFoundErrorcallers usually guard against.
Android vs iOS Mapping
| iOS Tool | Android Equivalent | Status |
|---|---|---|
| xcrun simctl | adb / avdmanager / emulator | ✓ Complete |
| IDB | adb shell uiautomator / input | ✓ Complete |
| iOS Simulator | Android Emulator | ✓ Complete |
| xcodebuild | Gradle wrapper | ✓ Complete |
| Accessibility tree | UI hierarchy dump | ✓ Complete |
| simctl privacy | pm grant/revoke | ✓ Complete |
| xcresult | Gradle test reports / JUnit XML | ⚠ Basic (being improved) |
Script Categories
🚀 Essential (Use Daily)
- app_launcher.py - Launch/terminate apps
- screen_mapper.py - Understand current screen
- navigator.py - Interact with UI elements
- gesture.py / keyboard.py - User input
🔧 Development
- build_and_test.py - Build projects and run tests
- log_monitor.py - Debug with filtered logs
- emulator_boot.py / emulator_shutdown.py - Device management
🧪 Testing
- accessibility_audit.py - Check accessibility compliance
- visual_diff.py - Visual regression testing
- test_recorder.py - Document test execution
- app_state_capture.py - Debug test failures
⚙️ Advanced
- privacy_manager.py - Test permission flows
- push_notification.py - Post to the shade as the shell; verify what an app posted
- status_bar.py - Fine-grained control
- emulator_create/delete/erase.py - CI/CD provisioning
- sms.py - Inbound SMS, and OTP login flows end to end (emulator-only)
- snapshot.py - ~2s state reset between tests (emulator-only)
- crash_triage.py - Structured crashes from the dedicated crash buffer
Typical Workflows
Manual Testing Flow
# 1. Launch app
python3 "$SKILL_DIR/scripts/app_launcher.py" --launch com.example.app
# 2. See what's on screen
python3 "$SKILL_DIR/scripts/screen_mapper.py"
# 3. Interact
python3 "$SKILL_DIR/scripts/navigator.py" --find-text "Login" --tap
python3 "$SKILL_DIR/scripts/navigator.py" --find-type EditText --index 0 --enter-text "user@test.com"
python3 "$SKILL_DIR/scripts/keyboard.py" --button enter
# 4. Verify
python3 "$SKILL_DIR/scripts/screen_mapper.py"
Automated Testing Flow
test_recorder.py is driven from the shell, one process per step — the session
is stored on disk, so nothing has to be held open between commands.
# 1. Start recording; the session id is printed, and later steps default to it
python3 "$SKILL_DIR/scripts/test_recorder.py" --start "Login Flow" --app-name MyApp
# 2. Execute test steps, capturing the screen after each interaction
python3 "$SKILL_DIR/scripts/test_recorder.py" --step "Launch app" --screen Splash
# ... interactions ...
python3 "$SKILL_DIR/scripts/test_recorder.py" --step "Verify logged in" \
--screen Home --assert "Home screen shown"
# 3. Finish: writes report.md + manifest.json (add --failed to mark it failed)
python3 "$SKILL_DIR/scripts/test_recorder.py" --stop
CI/CD Flow
# 1. Create fresh emulator
python3 "$SKILL_DIR/scripts/emulator_create.py" --device pixel_7 --api 34 --name test-device
# 2. Boot emulator
python3 "$SKILL_DIR/scripts/emulator_boot.py" --avd test-device --wait-ready
# 3. Build and test
python3 "$SKILL_DIR/scripts/build_and_test.py" --project . --test
# 4. Run UI tests
# ... your test scripts ...
# 5. Cleanup
python3 "$SKILL_DIR/scripts/emulator_shutdown.py" --serial emulator-5554
python3 "$SKILL_DIR/scripts/emulator_delete.py" --name test-device
Debugging Flow
# 1. Capture complete state
python3 "$SKILL_DIR/scripts/app_state_capture.py" --package com.myapp --output debug-snapshots/
# 2. Monitor logs in real-time
python3 "$SKILL_DIR/scripts/log_monitor.py" --app com.myapp --severity error,warning --follow
# 3. Check accessibility issues
python3 "$SKILL_DIR/scripts/accessibility_audit.py" --output audit-reports/ --verbose
Requirements
- macOS, Linux, or Windows
- Android SDK with platform-tools and emulator
- Python 3.12+
- ADB (Android Debug Bridge)
- Optional: Gradle for building
- Optional: Pillow for screenshot resizing
Installation
Environment Setup
# 1. Install Android SDK (via Android Studio or command line tools)
# Download from: https://developer.android.com/studio
# 2. Set environment variables
export ANDROID_HOME=$HOME/Library/Android/sdk # macOS
export PATH=$PATH:$ANDROID_HOME/platform-tools
export PATH=$PATH:$ANDROID_HOME/emulator
# 3. Verify installation
adb version
emulator -version
As Claude Code Skill
# As a plugin (preferred; updating is then two commands -- see README.md)
claude plugin marketplace add fluxxion82/android-emulator-skill
claude plugin install android-emulator-skill@fluxxion82
# From a clone: the skill is the INNER directory (this file's own), not the
# repository root, so clone the repo and link that directory into place.
git clone https://github.com/fluxxion82/android-emulator-skill ~/src/android-emulator-skill
# Personal installation
ln -s ~/src/android-emulator-skill/android-emulator-skill/skills/android-emulator-skill ~/.claude/skills/android-emulator-skill
# Project installation
ln -s ~/src/android-emulator-skill/android-emulator-skill/skills/android-emulator-skill .claude/skills/android-emulator-skill
Documentation
- SKILL.md (this file) - the script reference; the accurate inventory
- README.md - short orientation for humans
- examples/ - complete automation workflows (ships with the package)
In the source repository only:
- CLAUDE.md - architecture, conventions, and the recorded-fixture policy
- references/ - deep dives on adb, test patterns, accessibility (not included in the released package)
Platform limitations worth knowing
The clipboard is not reachable from adb. There is no way to set clipboard text from the shell on any modern Android:
cmd clipboardreports "No shell command implementation" on API 33 and 35 — the clipboard service exposes no shell command interface at all.service call clipboard <setPrimaryClip>cannot work either: the call takes aClipDataParcelable, whose text is marshalled withwriteString8()plus version-dependent fields, andservicecan only writei32/s16/binders.- The emulator console (
adb emu) has no clipboard command.
Reading and clearing are reachable — service call clipboard 3/4/9 with the
right signature is accepted for the shell uid, which holds
READ_CLIPBOARD_IN_BACKGROUND — but writing is not, so a paste-flow test needs
an app-side hook or a helper IME.
On an emulator only, the gRPC control API does expose setClipboard /
getClipboard (see $ANDROID_HOME/emulator/lib/emulator_controller.proto).
That needs a gRPC client and the emulator's auth token, and is not wired up here.
clipboard.py was removed in v0.6.0 for this reason: its surviving code path
called service call clipboard 1 with the pre-Android-10 signature and always
failed.
Jetpack Compose screens look different in the dump. Everything below was measured from recorded dumps of a real Compose app, not assumed — three plausible-sounding assumptions about Compose turned out to be wrong.
- The host class is
androidx.compose.ui.platform.ComposeView.AndroidComposeViewdoes not appear in the dump at all, so a detector looking for it matches nothing. - There are no resource-ids. A default Compose screen exposes only
android:id/content, which belongs to the AOSP FrameLayout.--find-idhas nothing to work with; use--find-textorscreen_mapper. - Interactive nodes carry no label of their own. Every clickable and
checkable node has
text=""andcontent-desc="". uiautomator dumps the unmerged semantics tree, so a clickableCardkeeps its Texts as separate children rather than merging them into one label — there is no concatenation anywhere.screen_mapperrecovers labels from a control's descendants (Button, Card, list row) and from row-adjacent siblings (Checkbox, Switch, icon), which is why it can name a control that the dump leaves anonymous.
To make your own Compose app addressable, opt into test tags as resource-ids on the root of the tree:
Modifier.semantics { testTagsAsResourceId = true } // once, at the root
Modifier.testTag("submit_button") // on each control
The tag then surfaces as a bare resource-id — submit_button, not
com.example.app:id/submit_button. Anything that splits a resource-id on
":id/" to recover a name drops every Compose tag on the screen.
Key Design Principles
Semantic Navigation: Find elements by meaning (text, type, ID) not pixel coordinates. Survives UI changes.
Token Efficiency: Concise default output (3-5 lines), with --verbose for
human detail and --json for machine-readable output. The reduction versus
piping raw tool output is large but has not been measured; a budget table is
planned rather than an invented percentage.
Accessibility-First: Built on standard accessibility APIs for reliability and compatibility.
Zero Configuration: Works immediately with Android SDK installed. No complex setup required.
Structured Data: Scripts output JSON or formatted text, not raw logs. Easy to parse and integrate.
Cross-Platform: Works on macOS, Linux, and Windows.
Real Devices: Unlike iOS, works with both emulators and real devices.
Android-Specific Features
Real Device Support: Works with both emulators and physical devices connected via USB/WiFi.
Multiple Emulators: Support for running multiple emulators simultaneously with batch operations.
Flexible Architecture: Works with both x86_64 and ARM emulator architectures.
Gradle Integration: Native integration with Android's Gradle build system.
Advanced Logging: Logcat filtering with regex, severity, and app-specific targeting.
Permission Testing: Programmatic grant/revoke of runtime permissions.
Notification Testing: Post a notification into the shade as the shell, and
read back what a package has actually posted. Delivering into an app's own FCM
handler is not reachable from adb — see push_notification.py above.
Status & Roadmap
v0.7.0 — the agent loop is verified from this skill's own printed output.
Earlier releases checked that a fix existed at a given line. This one checks
that the loop closes: a test reads what screen_mapper prints and feeds each
printed name back into navigator, so "implemented but unreachable" fails.
screen_mappernames up to 15 controls per bucket in the default report (the full inventory is in--json), andnavigatorfinds and taps every name it printed — including Jetpack Compose captions, which the dump leaves anonymous and which are resolved to the control that owns them. A caption whose only enclosing "owner" is a scroll container is refused, not tapped at that container's centre.- A bare
--tapis refused. A tap lands inside the rectangle the name describes — the test resolves that rectangle from the dump with its own parser, so navigator is checked against the screen rather than against itself. --launchand--open-urlwait for the activity —am start -W, withStatus: okrequired — instead of returning as soon asam startexits.emulator_shutdowncannot power off a handset.reboot -pis gone and a non-emulator serial is refused before anything is issued. One tri-state probe answers "which AVD is this serial" for every caller, so "the device listing itself failed" is no longer read as "not running".- Every value crossing the device shell is quoted — all 19 sites, each enumerated positively by a guard, so "zero unquoted sites" cannot come from a blind detector.
- Every failing mode exits non-zero. A runtime sweep drives 23 documented
invocations across 16 scripts against a toolchain where every tool fails, and
asserts the exit status rather than the message. The JSON failure shape is
not yet uniform, and the sweep does not assert it:
app_launcher,screen_mapper,navigator,anr_watcher,app_state_capture,device_listand theemulator_*lifecycle scripts answer{"error": ...}under--json, whilestatus_bar,appearance,privacy_manager,keyboard,gesture,location,emulator_createandbuild_and_teststill answer{"success": false, "message": ...}. - A missing SDK tool says so in the lifecycle scripts —
device_list,emulator_boot,emulator_delete,emulator_eraseandemulator_selectorroute throughcommon/sdk_tools.py, which names where the tool was looked for and gives thecmdline-toolsremedy instead of returning an empty list. A tool that ran and genuinely found nothing still exits 0. (device_listkeeps a missingavdmanageras a warning: it only decorates AVDs the emulator already listed.)
The correction remai
…(truncated)