A pool of disposable Android devices for AI coding agents.
When several agents work on the same mobile app in parallel, they all end up installing onto the same test device and overwriting each other. droidpool gives each agent (or each git worktree) an exclusive, clean, throwaway Android instance backed by redroid containers, plus a browser "device wall" so a human can watch every screen at once, zoom into one, and take over when an agent needs help.
Maintained by Guangzhou Daboshi Supply Chain Co., Ltd. — https://daboshi.cn The
fancyCachierGitHub organization is the company's engineering org.中文文档 → · License: MIT
- Leases, not devices.
droidpool claimin a worktree returns an exclusive device;droidpool releasewipes it and returns it to the pool. Idempotent per worktree: callingclaimtwice gives you the same device. - A watchdog that assumes agents die. Three gates reclaim a device: idle timeout (no CLI activity for 30 min), TTL (4 h default), and a hard lifetime cap (24 h). Heartbeats prove liveness but do not extend the TTL, so a stuck agent cannot hold a device forever.
- Real reset. Release removes the container and wipes its data
directory via a privileged helper container. Redroid's overlayfs mode
(
/data-base+ per-device/data-diff) makes this a zero-copy operation. - Admission control on memory, not swap. The pool refuses new leases when the node's available memory drops below one device's footprint. Swap usage is displayed but not used as a gate: it is a lagging, sticky symptom that stays high long after pressure is gone.
- Device wall. One page shows every device as a live thumbnail with its
lease, branch and remaining time. Click a tile to zoom: one WebSocket carries
H.264 video (scrcpy protocol, decoded with WebCodecs) down and live input up.
Pointer down / move / up are forwarded as they happen through scrcpy's
control socket, so drag, long-press and multi-touch (Alt/Ctrl + drag pinches)
behave like a finger on the device; the wheel becomes a native scroll event.
With the canvas focused the physical keyboard is forwarded too, modifiers
included, and the clipboard works both ways: the device's clipboard syncs up
to the page, and pasting sends text down — which is also how non-ASCII text
gets in, since Android's text injection silently drops characters the virtual
keyboard has no key for. Screenshot to PNG or clipboard. At most four devices
stream at once. WebCodecs only exists in a
secure context: open the wall via HTTPS or
localhost(an SSH port forward works); on a plainhttp://<lan-ip>URL the browser has noVideoDecoderand the page falls back to the 3 fps screenshot stream and says so. See "HTTPS for the device wall" below. - Human takeover protocol. An operator can flag a lease as "human takeover";
droidpool statusexits 10 so the agent knows to stop and wait. - Per-device egress. Each device can route its public traffic through its own
SOCKS5 upstream, set from the device wall and switchable while a lease is
running — the upstream lives in a separate relay container, so changing it
rebuilds only that container and the device never notices. LAN stays direct:
adb, the backend and the control plane must not detour through a proxy, and
the device would go unreachable if they did. Off by default (
egressin the node config); the node needs thetunmodule loaded. - Fast UI dumps.
uiautomator dumpspends its time starting an ART process and loading the framework jar, not walking the tree — measured 321–622 ms for a 27-node hierarchy. A resident agent keeps the process and the UiAutomation connection alive, which brings a dump down to ~20 ms. Available asdroidpool ui-dumpand asGET /api/devices/{id}/ui. - A camera, if you want one. Redroid ships no camera at all. Point a device
at an RTSP URL — from the wall, the CLI or the API — and it gets a working
Camera2 device; the host transcodes the stream into a per-device v4l2loopback
node that the container sees as an ordinary UVC webcam. Changing the URL
rebuilds only the feed container, so the device keeps running. Off per device
by default, deliberately: one 720p15 feed costs about 70 % of a core (MJPEG
encoding dominates, and this ffmpeg build has no hardware JPEG encoder even
though the SoC has one), so eight of them would eat most of the node. Needs a
self-built image, see
device/redroid-patches/. - Hardware identity. Devices can report whatever
Build.MODEL / BRAND / MANUFACTURER / DEVICE / PRODUCTand serial you want instead ofredroid14_arm64_only, so model-dependent code paths in the app under test can be exercised. A pool-wide default lives in the node config; per-device overrides come from the API ordroidpool identity. These are boot-timero.*properties, so changing them rebuilds that one device (wiped, lease kept, adb address unchanged). What stays visible:ro.hardware=redroid, a SwiftShader GL renderer string, no sensors, no telephony. This is for exercising your own app, not for defeating attestation. - Mock location.
droidpool location 23.1291,113.2644(or the API, or a pool-wide default) injects a fix into thegpsandnetworkproviders through Android's own test-provider mechanism, live, no rebuild. It is replayed after every reset. Apps seeLocation.isMock() == true; SDKs that filter mock fixes (Amap, Baidu) need their mock switch turned on.
| Metric | Value |
|---|---|
Container boot to sys.boot_completed |
11–13 s |
| Concurrent devices actively driven (p95 within 2× of single) | 10 |
| Resident devices (app idle in foreground) | 12 |
| Video, scrcpy + WebCodecs | 11 fps decoded, 15.8 fps at the server |
| Click-to-pixel latency | 627 ms median (down from 1 491 ms with screencap) |
| Input injection via scrcpy control socket | 42 ms (vs. 119 ms via adb shell input) |
The remaining latency is the device's own software rendering; redroid has no
hardware encoder. See docs/2026-09-03-远程操作方案对比.md for the full
comparison with scrcpy, ws-scrcpy and STF, and why screencap-based streaming
tops out at 3 fps.
agent host (Linux / macOS) control plane (any Linux) device node (RK3588 / x86)
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ droidpool CLI │── HTTP ───▶│ droidpoold │─ docker ─▶│ dockerd │
│ claim / run / │ │ leases (SQLite) │ over SSH │ redroid-1 :5561 │
│ release / watch │ │ watchdog │ │ redroid-2 :5562 │
│ │ │ health checker │ │ … │
│ adb -s <ip:port> ────┼────────────┼──────────────────────┼──────────▶│ (adb exposed) │
└──────────────────────┘ │ device wall (htmx) │ └──────────────────────┘
│ scrcpy client ──────┼─ adb fwd ──▶ scrcpy-server.jar
operator browser ──── SSE/H.264 ───▶│ │
└──────────────────────┘
The node runs nothing but docker. Everything else lives on the control plane,
so a node can be reimaged in minutes and a second node is one more [[nodes]]
block in the config.
Any Linux host with docker and a kernel that has binderfs and memfd (RK3588 vendor 6.1 kernels and mainline 6.x both work):
docker pull redroid/redroid:14.0.0_64only-latest
mkdir -p /data/droidpoolmake dist
cp deploy/config.toml.example /opt/droidpool/config.toml # edit nodes, ports, edge_default
echo "DROIDPOOL_TOKEN=$(openssl rand -hex 16)" > /opt/droidpool/env
deploy/deploy.sh <ssh-alias> # installs binary, unit file, scrcpy-server jarOn first start droidpoold reconciles stale containers on the node, builds a
golden /data-base (animations off, screen always on, locale/timezone set), and
brings the pool up to max_devices. Optional: set SCRCPY_SERVER_JAR to enable
H.264 streaming; without it the wall falls back to screencap.
export DROIDPOOL_URL=http://<control-plane>:8600
export DROIDPOOL_TOKEN=<from /opt/droidpool/env>
cd my-worktree
droidpool claim # → device id, adb address, lease expiry
droidpool run --apk build/app.apk # install → seed endpoint → launch → skip onboarding
adb -s $(droidpool addr) shell ... # drive the UI however you like
droidpool releaseLong task? droidpool watch & keeps the heartbeat alive.
Several agents on one machine sharing one checkout? Give each a distinct
DROIDPOOL_SESSION: it becomes part of the idempotency key and of the local
state file name, so the agents stop reusing one lease and piling onto a single
device. The dsh plugin sets it automatically. claim warns on stderr whenever
it reuses a lease that this directory did not create.
See docs/agent-guide.md (Chinese) for the full
playbook, including the UI-driving pitfalls we hit.
| Command | Purpose |
|---|---|
claim [--ttl 4h] |
Lease a device for the current worktree (idempotent per host + worktree, plus DROIDPOOL_SESSION when set) |
addr |
Print the adb address, for adb -s $(droidpool addr) |
run [--apk …] |
Install, seed backend endpoint, launch, auto-dismiss first-run dialogs |
seed-edge [--host --port] |
Write backend endpoint + certificate pin into the app's prefs |
status |
Show lease; exit 10 while a human has taken over |
heartbeat / watch |
Prove liveness once / continuously |
release |
Return the device (it gets wiped and rebuilt) |
devices |
List the pool |
battery [--level 1..100] |
Fake a battery. Redroid has none, so apps read 0 % — --status charging|discharging|full, --temp, or --reset |
ui-dump [--n 5] |
Dump the view hierarchy as XML through a resident agent (~25 ms vs ~380 ms for uiautomator dump) |
camera --rtsp … | --off |
Feed an RTSP stream to the device's camera, or stop it |
identity --model … --brand … | --reset |
Set the reported hardware model (rebuilds the device, keeps the lease) |
location <lat,lng> | --off |
Mock the device's location, live |
| Agent runtime | Integration |
|---|---|
| Claude Code | a skill that wraps the CLI and hooks into the worktree lifecycle |
| DeepSeek harness (dsh) | /droidpool command plugin; usage and pitfalls are injected into the system prompt |
| Any MCP client | droidpool-mcp (stdio) exposes droidpool_claim / run / status / heartbeat / release / devices / identity / location |
claude mcp add droidpool -e DROIDPOOL_URL=http://<control-plane>:8600 -e DROIDPOOL_TOKEN=<token> -- droidpool-mcpLease endpoints require Authorization: Bearer <token>. Wall endpoints
(/api/wall, screenshots, streams, input) are unauthenticated by design for
LAN-only deployments; put a reverse proxy in front if you need otherwise.
POST /api/leases claim {host, worktree, branch, head_sha, ttl_min?}
POST /api/leases/{id}/heartbeat liveness (does not extend TTL)
POST /api/leases/{id}/renew extend TTL
POST /api/leases/{id}/human {takeover: bool, note?}
DELETE /api/leases/{id} release → async reset
GET /api/devices · /api/leases · /api/health
GET /api/events SSE, full snapshot on every change
GET /api/devices/{id}/ws WebSocket: H.264 access units and device clipboard down;
live touch / scroll / key / text / clipboard up
GET /api/devices/{id}/stream.h264 multipart H.264 (scrcpy), kept for curl-based measurements
GET /api/devices/{id}/stream.mjpg multipart JPEG/PNG (screencap fallback)
POST /api/devices/{id}/input {type: tap|swipe|key|text, …} (fallback when no WebSocket session)
GET /api/devices/{id}/ui view hierarchy XML via the resident agent
PUT /api/devices/{id}/egress {proxy: "socks5://host:port"} — empty string means direct
PUT /api/devices/{id}/camera {rtsp: "rtsp://host/live"} — empty string stops the feed
PUT /api/devices/{id}/identity {model, brand, manufacturer?, device?, name?, serial?} — {} reverts to the
pool default; rebuilds the device synchronously (20–40 s)
PUT /api/devices/{id}/location {location: "lat,lng"} — empty string reverts to the pool default
- No Bluetooth, USB passthrough or GMS inside the containers. Test those on real
hardware. A camera is possible but not on by default: it needs a self-built
image plus an RTSP feed on the host, see
device/redroid-patches/. - No hardware video encoding. RK3588 has one, but redroid's Android side only ships software codecs; the bottleneck is display readback, not encoding.
- No multi-tenant auth. This is an internal-network tool.
cmd/droidpoold control plane daemon
cmd/droidpool agent CLI
cmd/droidpool-mcp MCP server (stdio) wrapping the HTTP API
internal/pool lease model, state machine, watchdog, health checker, manager
internal/store SQLite persistence (idempotent claim, TTL queries)
internal/node docker-over-SSH node driver, golden image, reconciliation
internal/adb screenshot / input via adb (fallback path)
internal/scrcpy scrcpy 4.1 protocol client: video + control socket
internal/api HTTP API, SSE hub, device wall (embedded htmx pages)
internal/uiagent client for the resident on-device UI dump agent
device/uiagent that agent's source (Java, built to a dex with the public SDK)
device/redroid-patches camera support for a self-built redroid image + host RTSP pipeline
bench/ reproducible smoke, login-flow, concurrency sweep scripts
deploy/ systemd unit, config template, deploy script
docs/ roadmap, baselines, design comparisons (Chinese)
WebCodecs only exists in a secure context, so the zoomed view needs HTTPS (or
localhost). droidpoold can terminate TLS itself:
[tls]
listen = "0.0.0.0:443"
cert = "/opt/droidpool/tls/fullchain.pem"
key = "/opt/droidpool/tls/privkey.pem"
wall_url = "https://droidpool.example.com" # http:// wall pages 302 here; the API stays on httpThe certificate files are watched: replace them and the next handshake uses
the new pair, no restart. Any ACME client can produce them; deploy/cert/
holds the pipeline we use (acme.sh with DNS-01 on one host, pushed over ssh to
a forced-command receiver next to droidpoold). Binding port 443 as a
non-root user needs AmbientCapabilities=CAP_NET_BIND_SERVICE in the systemd
unit, which deploy/droidpoold.service sets.
make test # go vet + go test -race
make dist # cross-compile daemon (linux/amd64) and CLI (linux/amd64, linux/arm64, darwin/arm64)Every package ships with tests that were mutation-checked: we deliberately broke
the code under test and confirmed the relevant test turned red before keeping
it. See CLAUDE.md for contributor conventions, including what must never be
committed to this public repository.
- redroid — Android in a container
- scrcpy — whose server jar and wire protocol make low-latency streaming possible; the protocol details here were confirmed by packet capture against v4.1
- coder/websocket — the WebSocket transport behind the device wall's zoomed view