Skip to content

Latest commit

 

History

66 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

droidpool

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 fancyCachier GitHub organization is the company's engineering org.

中文文档 → · License: MIT

What you get

  • Leases, not devices. droidpool claim in a worktree returns an exclusive device; droidpool release wipes it and returns it to the pool. Idempotent per worktree: calling claim twice 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 plain http://<lan-ip> URL the browser has no VideoDecoder and 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 status exits 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 (egress in the node config); the node needs the tun module loaded.
  • Fast UI dumps. uiautomator dump spends 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 as droidpool ui-dump and as GET /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 / PRODUCT and serial you want instead of redroid14_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 or droidpool identity. These are boot-time ro.* 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 the gps and network providers through Android's own test-provider mechanism, live, no rebuild. It is replayed after every reset. Apps see Location.isMock() == true; SDKs that filter mock fixes (Amap, Baidu) need their mock switch turned on.

Measured on an 8-core RK3588S with 16 GB RAM

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.

Architecture

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.

Quick start

Node

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/droidpool

Control plane

make 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 jar

On 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.

Agent

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 release

Long 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.

CLI

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

Integrations

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-mcp

HTTP API

Lease 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

What it deliberately does not do

  • 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.

Repository layout

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)

HTTPS for the device wall

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 http

The 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.

Development

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.

Acknowledgements

  • 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

About

A pool of disposable redroid Android devices for AI coding agents, with a browser device wall for humans to watch and take over

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages