android-driver
Drive an Android emulator as a deterministic test harness — from Claude Code.
You ask an agent to reproduce a bug in your Android app. It dumps 80 KB of accessibility XML into its own context, taps something that turns out to be the wrong element, and when the bug does not appear it cannot repeat what it just did — because the app is now three screens deep in a state nobody recorded.
android-driver is built for that loop instead: build → install → drive → assert → reset → repeat.
> reproduce the crash when the bio field goes over 100 characters
snapshot_load("clean") 1.9s
open_settings() ok
type_text(id=text_field_Bio, text=110 chars) ok
expect_log("StringIndexOutOfBoundsException") ok — matched
expect_no_crash() FAILED — 1 crash record
runs/20260901-080304-repro-set-06/report.md
→ java.lang.StringIndexOutOfBoundsException: begin 0, end 120, length 110
at ...screens.SettingsScreenKt.SettingsScreen$textFields(SettingsScreen.kt:149)
Three attempts from the same snapshot, three identical results, and a directory of evidence to point at. That is the whole idea.
Install
It is a Claude Code plugin: one install brings the tools, a skill that teaches Claude the loop, and three slash commands.
claude plugin marketplace add earlzdev/android-driver
claude plugin install android-driver@android-driver
There is no venv to manage — the plugin builds the Python server from source on demand, and passes your project directory to it so your config is found wherever Claude was launched from.
Requirements: adb and the Android SDK's emulator on PATH, Python ≥ 3.10. For the faster
uiautomator2 backend run python -m uiautomator2 init once per device; without it the server falls
back to a pure-adb backend that needs nothing installed on the device.
Not using Claude Code, or want it as a plain MCP server? It is on PyPI, so there is nothing to clone:
claude mcp add android-driver -e ANDROID_DRIVER_PROJECT="$PWD" -- uvx android-driver
That gets you the tools but not the skill or the slash commands, which are plugin components. See docs/installation.md for the config any other MCP client wants.
Quickstart
/android-driver:setup
It checks your toolchain, finds your applicationId and build command, detects whether you are on
Compose or Views, writes a starter .android-driver.yaml, boots an emulator and proves the loop
works. Then:
/android-driver:smoke # build, install, walk the main flows, assert nothing broke
/android-driver:repro <what is broken> # reproduce it from a snapshot and leave evidence
Or just talk to it — the android-testing skill loads automatically when a task involves driving the
app, so "check that login still works on a fresh install" does the right thing without ceremony.
Why it works this way
Four decisions do most of the work.
Snapshots, so a repro is actually reproducible. snapshot_save freezes the emulator's exact
state; snapshot_load restores it and waits until the device is genuinely drivable again — 1.8–2.2s
measured on a Pixel 7 AVD. Reinstalling and re-navigating costs 30–90s and drifts a little each
time. An agent testing thirty variations of a hypothesis needs the cheap, identical option: the
difference between two attempts only means something if everything else was the same.
A screen index instead of a wall of XML. A raw uiautomator dump is 50–200 KB per screen — tens
of thousands of tokens for a model that just wants to know what it can tap. screen returns this:
device=emulator-5554 app=com.example.app/.MainActivity screen=1080x2400 driver=uiautomator2
#5 [Scroll] id=settings_container (scrollable) @(540,1236)
#7 [Text] "Settings" id=homepage_title @(235,472)
#18 [EditText] "" id=text_field_Bio @(540,1018)
Then tap(ref="#7"). Two orders of magnitude smaller, and it reads like a menu. The raw tree is
still there behind dump_ui_xml for when you genuinely need it.
Assertions that collect their own evidence. expect_visible polls, so it is safe immediately
after a tap and will not flake on an animation; when it fails it hands back the screen index that
was there, plus a screenshot and hierarchy dump on disk. expect_no_crash reads the crash buffer
as well as main, because a native abort never reaches main at all. Wrap a sequence in
run_start / run_end and you get runs/<id>/ holding a timeline, a report, the logcat slice for
exactly that window, and every failure artifact — so an agent cites a directory instead of describing
what it saw.
Your flows as first-class tools. The six steps every test starts with — sign in, create an order,
join a call — go into .android-driver.yaml once and become real MCP tools with typed parameters. An
agent sees login(email, password) in its tool list rather than rediscovering the flow from a screen
dump every session. Recipes run the same code path as the hand-driven tools, so the two cannot drift.
Plus the device knowledge that costs an afternoon each to learn
- Uninstall-then-install, not
pm install -r— debug APKs from different branches carry different signing keys and otherwise fail withINSTALL_FAILED_UPDATE_INCOMPATIBLE. - Check
mInputShownbefore pressing Back, so dismissing the keyboard never dismisses the dialog behind it. - Write to Compose
TextFields through the accessibility node, not tap-then-type, which lands text in the wrong field. - Settle after a click before the next query, or you read pre-animation state.
- An
appopspass for OEM permission overlays that keep blocking afterpm grantreports success.
Configure
.android-driver.yaml at your project root is what makes a generic tool specific to your app.
/android-driver:setup writes a starter for you.
app:
package: com.example.myapp
activity: .MainActivity # optional; the launcher intent is resolved otherwise
build:
command: ./gradlew :app:assembleDebug
apk_glob: app/build/outputs/apk/debug/*.apk
driver:
backend: auto # auto | uiautomator2 | adb
selectors: # scanned, so a typo is a warning rather than a mystery
sources: ["app/src/main/**/*.kt"]
recipes: # each becomes an MCP tool with typed parameters
login:
params: {email: {required: true}, password: {required: true, secret: true}}
steps:
- launch:
- type: {desc: text_field_Email, text: "{{email}}"}
- tap: {desc: login_button}
- expect_visible: {desc: home_greeting, timeout_s: 20}
The file is optional: with no config every generic tool still works — you pass pkg= explicitly and
lose build_app and recipes. It is found by walking up from your project and then, failing that, up
to three levels down, so an app in app/ or android/ is discovered without configuration.
Full reference: docs/configuration.md · recipe and step syntax:
docs/recipes.md · worked examples for Compose and View projects:
examples/.
Tools
45, plus one per configured recipe.
| Group | Tools |
|---|---|
| Emulator | list_avds start_emulator stop_emulator wait_for_boot snapshot_save snapshot_load snapshot_list snapshot_delete |
| Device | list_devices select_device device_info |
| App | build_app install_app uninstall_app app_info launch_app force_stop clear_app_data |
| UI | screen tap tap_xy long_press type_text swipe scroll_to press_key screenshot dump_ui_xml |
| Assertions | expect_visible expect_gone expect_log expect_no_crash |
| Runs | run_start run_end run_list record_start record_stop |
| Recipes | list_recipes run_recipe check_recipes list_selectors reload_config |
| Logs | logcat_clear logcat_read |
| Shell | shell |
The loop, in tool calls
start_emulator(avd="Pixel_7") # reuses one that is already running
install_app(build_first=True)
launch_app()
snapshot_save("clean") # ← the state every attempt returns to
run_start("issue 412: crash on empty search")
… login() / tap / type_text / expect_visible …
expect_no_crash()
run_end() # → runs/<id>/report.md
snapshot_load("clean") # next variation, from identical state
docs/agent-guide.md is a CLAUDE.md fragment you can drop into a project so
an agent picks this up without being told.
Try it without your own app
The repo ships FlakyDemo — a Compose app with five screens and 27 deliberately
planted bugs, each documented in test_app/BUGS.md with what was actually
observed rather than what was intended. Crashes, races, state lost on rotation, a Save button that
reports success and silently does nothing, and several bugs that only show up one run in three.
Its flake generator is seeded, so --el flake_seed 42 replays the same failures every time and
--ez flake_enabled false turns them all off for a clean baseline. It ships with 11 recipes.
It is the fastest way to see what the tool is for — and a fair test of whether it earns its keep,
since a good number of the planted bugs are the kind a tap-and-screenshot agent cannot catch at all.
The Settings screen alone reports "Saved" in the UI while the log says
outcome=noop reason=terms_not_accepted and nothing was written.
Development
uv sync --extra dev
uv run pytest # 129 unit tests against a fake driver, no device needed
uv run ruff check src tests
Setup, the live suite, and the packaging pitfalls worth knowing about are in CONTRIBUTING.md.
Status
Early, but working end to end. Emulator lifecycle, snapshots, both driver backends, the screen index, assertions, run bundles, screen recording, recipes and selector scanning are implemented and covered by tests; the live suite passes against a Pixel 7 AVD. Packaged as a Claude Code plugin with a skill and three commands.
Not on PyPI — the plugin builds from source, so it does not need to be. Roadmap: docs/roadmap.md. Issues and pull requests welcome.
License
MIT
Metadata
Release files for android-driver 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| android_driver-0.1.0.tar.gz | 79.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| android_driver-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 141.6 kB
Release files / android_driver-0.1.0.tar.gz
| Download URL | android_driver-0.1.0.tar.gz |
|---|---|
| Size | 79.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8b697689b3ce60503a94d0dc2ec98789259e8f569fb93e489ea353d38bbb77bf
|
|
BLAKE2b-256 checksum How to use checksums |
7f2dc5e3a5fa48c8efd99508d7535591117ef4f2398cf5752e7dc95acd98f100
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.
Transparency logRelease files / android_driver-0.1.0-py3-none-any.whl
| Download URL | android_driver-0.1.0-py3-none-any.whl |
|---|---|
| Size | 62.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aa71c6029bd75edfe7d570f75af5cd20554d283aa6683db536039ec4dd664e29
|
|
BLAKE2b-256 checksum How to use checksums |
beffc2e216d195393d50acb8c69ef8feba3d9a68b61c50759aaacd6bc8b87cbd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.
Transparency log