A remote Android device you drive from a browser or over the network: a headless emulator, a shell workbench, and a web UI that streams the screen and gives you a terminal (screenshots).
Built for poking at an APK during development, running e2e tests against a reproducible device, or providing a sanboxed agent access to an Android emulator. The device runs on demand — it wakes when the UI or API wants it and stops itself after 30 idle minutes.
Security Warning This project has not been evaluated for security issues. Authentication and trust boundaries are best effort. The authorization model is essentially default admin and intended for single user use on a private network. Prompted with "No bugs, no mistakes".
Requirements: x86_64 Linux with KVM (/dev/kvm readable by your user), Docker or Podman with Compose, and just. Cargo runs containerized — no Rust toolchain needed on the host.
just setup # generate keys, seed .env
$EDITOR .env # set DROID_PASSWORD
just compose-up # all three services (just compose-down to stop)Web UI: http://localhost:8080. From a script or CI, use
emucli:
export EMU_SERVER=http://localhost:8080 EMU_BASIC=admin:hunter2
emucli wait --start # wake the device, block until booted
emucli install app.apk --launch com.example.app
emucli tap --text "Sign in" # finds the node, taps its centre
emucli screenshot -o shot.png
emucli logcat --dump --lines 200 --tag MyTag --priority W
emucli pull /sdcard/shot.png| component | what it is | port (default) |
|---|---|---|
emulator/ |
the device: headless Android under QEMU/KVM | 5555 adb, 8554 gRPC |
workbench/ |
dev shell: sshd + adb + SDK tools |
2222 ssh |
emuserverd |
the server: Axum HTTP/WS, web UI (wasm) embedded | 8080 http |
emuctld |
lifecycle daemon in the emulator container: supervises the emulator, forwards adb/gRPC, binds guest-facing tunnel listeners | 8560 http |
emucli |
the CLI client — drives everything over HTTP | — |
emutunnel |
the reverse-tunnel client as a standalone binary | — |
The four binaries are one Rust workspace, droiddriver-rs/.
- The server is standalone. It speaks gRPC (screen, input) and the adb
protocol (installs, tests, files, device state) natively — no
adbbinary, no SSH. Every device route works with the workbench absent; the workbench is the human escape hatch behind/termand the only consumer of SSH credentials. - Auth is the server's job; reachability is everything else's. adb and
gRPC are effectively unauthenticated (
google_apisimages shipro.adb.secure=0), so the bind address is the access control. Image defaults bind everything except the web service to127.0.0.1— the Kubernetes-pod layout. Compose overrides the binds onto its bridge network but still publishes adb to the host on127.0.0.1only. The lifecycle API is gated by a sharedDROID_EMUCTL_KEY. - The device is disposable. The pristine AVD ships in the image; the data
volume is reseeded whenever it is empty or wiped (
ANDROID_WIPE_DATA=1for factory-fresh runs). The emulator container is the root appliance holding/dev/kvm; the workbench and its home volume survive its restarts.
Prebuilt images: ghcr.io/<owner>/droid-driver-{emulator,workbench,serve}.
Build locally with just build-container-all; test with
just test-cargo-all; just with no arguments lists every recipe.
| doc | covers |
|---|---|
| CONFIGURATION.md | auth modes, secrets, every env var and build arg, SELinux/rootless podman |
| KUBERNETES.md | the one-pod layout: device plugin for KVM, exec probes, binds, NetworkPolicy |
| REVERSE_PROXY.md | TLS, nginx auth_request + body size, WebSocket and SSE routes |
| CLI.md | emucli in full: getting it, every command, CI patterns |
| APIS.md | the HTTP API: install, tests, input, files, logcat, tunnel, keys, status codes |
MIT (see LICENSE) — except the vendored
droiddriver-rs/proto/emulator_controller.proto slice, which is from
google/android-emulator-container-scripts
and stays Apache-2.0.


