A Zoo Enclosure for the Android SDK: android-lab

The Android SDK is a wild animal. Let it loose in your house and it marks territory everywhere: an SDK, a pile of build tools, an emulator, a system image, a JDK it likes better than yours, a ~/.android directory full of keys and AVD configs, a ~/.gradle cache the size of a small country, and some background daemon that keeps eating RAM long after you closed the thing. Six months later there are three SDK versions in the walls, two emulators that won’t boot, and you have no idea which of them your build is actually using.

You don’t domesticate an animal like that. You build it an enclosure, you watch it through the glass, and you feed it through the bars.

I was building Dikciz Launcher, an Android home screen app, and I refused to let the beast into my machine. So the build toolchain and the emulator went into Docker. Then the cage grew a screenshot command, a UI dump, port forwarding, a few dozen device fixtures, and at some point it was obviously its own thing and not part of a launcher. So I ripped it out into its own repo: android-lab. Build an Android app, boot a phone, poke its UI. Your host gets Docker, Make, Git and /dev/kvm. Nothing else gets out.

Pouring the concrete: two images, built on your own daemon

The enclosure is two Docker images, both built locally. Nothing gets pushed to any registry, ever, because these images carry Google’s SDK artifacts and I’m not redistributing that crap. You read the Android SDK license, you accept it explicitly, you build:

git clone https://github.com/psyb0t/android-lab.git
cd android-lab
ANDROID_LAB_ACCEPT_ANDROID_LICENSES=yes make accept-licenses
make build

That leaves you with two tags, both derived from the VERSION file:

android-lab:0.13.0
android-lab:0.13.0-emulator-api36

The tool image is ADB, Java 17, the API 36 platform and build tools, Python, pytest, shell tooling, Docker and Compose, noVNC and the lab scripts, all embedded at /opt/android-lab with an android-lab command on PATH. The emulator image is the emulator, a Google APIs x86_64 system image and its display stack, on top of a digest-pinned ubuntu:24.04.

The animal doesn’t get to bring its own food either. resolve_android_downloads.py reads Google’s own repository metadata at build time, pulls the SHA-1 for each artifact out of it, and refuses to continue if what comes back isn’t a 40-character hex checksum. Rebuild after updating the checkout and Docker reuses every unchanged layer, so it’s not the full download punishment every time.

Feeding time: an APK without a single Android thing on the host

From any Android project on your machine:

cd /path/to/your/android-project
docker run --rm --init --user "$(id -u):$(id -g)" \
  -e ANDROID_LAB_WORKSPACE=/work -e ANDROID_PROJECT=. \
  -e GRADLE_TASK=:app:assembleDebug \
  -v "$PWD:/work" -w /work \
  android-lab:0.13.0 android-lab gradle

The APK lands in your project’s normal build output. It runs as your UID and GID, so you don’t end up with root-owned droppings in your source tree that you have to sudo rm later.

The keeper is paranoid about what goes through the bars, and every one of these is an actual error path in gradle-project.sh:

  • ANDROID_PROJECT must be a relative directory inside the workspace. No ../../ traversal, no symlinks escaping out.
  • The project needs an executable Gradle wrapper and its properties file.
  • That wrapper must pin distributionSha256Sum. No checksum, no build. A Gradle wrapper that downloads whatever it’s told is remote code execution with extra steps.
  • GRADLE_TASK takes one fully qualified task. Not a shell string, not a list of whatever.
  • ANDROID_LAB_GRADLE_MAX_WORKERS accepts 1 through 10.

Gradle caches and the debug signing identity go into the project’s own .android-lab/ directory, not your home. The signing key is stable, so a rebuilt APK installs over the old one instead of Android screaming about a signature mismatch. Put .android-lab/ in your .gitignore and never delete it to “upgrade”: it also holds your saved phone.

Portions are rationed. Tool containers get the Docker host’s CPU count capped at 10, 8g of memory with swap capped at the same 8g, and a 2048 PID limit, and Gradle workers follow the CPU limit. Want your machine back while it compiles? make build ANDROID_LAB_TOOL_CPU_LIMIT=2 ANDROID_LAB_GRADLE_MAX_WORKERS=2. Shortcut targets app-build, app-test and app-lint map to :app:assembleDebug, :app:testDebugUnitTest and :app:lintDebug.

Behind the glass: a phone you watch from a browser tab

make run
make device-info
make screenshot
make ui-layout
make stop

make run boots a persistent emulated phone: 1080×2400, four virtual cores, 4 GiB guest RAM, KVM accelerated. Then you open http://127.0.0.1:61326/vnc.html?autoconnect=true&resize=scale and there’s your phone in a browser tab through noVNC. That’s the viewing window. make stop tears down this workspace’s containers and networks but leaves the phone disk alone, so your installed apps and settings are still there next boot. make restart bounces it without resetting anything.

The enclosure itself is a Compose file with six services: keygen generates the ADB keys, emulator runs the phone, adb-bridge, forward and web-vnc handle access, and lab runs device operations. Every one of them drops all capabilities, sets no-new-privileges, refuses to pull images (pull_policy: never), and gets a PID and memory limit. The keygen service runs with network_mode: none and a read-only root, because a container whose entire job is writing one key pair doesn’t need the internet. The emulator caps at 4 CPUs and 8g, and its private ADB endpoint is emulator:5556 on an internal network. That’s the only device any operation ever talks to. Not your host ADB, not USB, not the real phone in your pocket.

The keeper’s clipboard: UI state as JSON

Staring through the glass works for humans. For anything automated, a PNG is useless and raw UIAutomator XML is a fucking swamp. So make ui-layout dumps the hierarchy and runs it through uiautomator_to_json.py, which flattens every node into compact JSON: className, resourceId, text, contentDesc, an interactions list (clickable, scrollable, longClickable and friends), a state list (checked, enabled, focused, selected), visibleToUser, the bounds, and a precomputed center x/y. That last one is the whole point: you read the JSON, find the button, tap its center. No pixel guessing.

Screenshots, UI XML and the layout JSON all land in .android-lab/shared/artifacts/.

Enrichment: faking a whole life for the phone

Captive animals need stimulation or they never show you how they behave in the wild. The Makefile defines 36 device targets, and make help lists them. The boring essentials are there: apk-install, app-start, app-stop, app-current, device-shell, file-push, file-pull, home-role-set to make your app the default launcher, accessibility-on and accessibility-off.

Then there are the emulator event fixtures, which exist because a launcher has to react to the real world and I wasn’t going to walk around outside with a laptop to test that shit. You can fake a GPS fix, set sensor values, receive an SMS from whoever you like, flip AC power, Wi-Fi, Bluetooth, thermal state, power save, device idle, night mode, orientation, font scale, ringer mode, notification policy access and the interruption filter. On top of that there are calendar fixtures to create, update and delete events, and contacts fixtures to create and delete people. Everything takes named Make variables like EMULATOR_SMS_SENDER and EMULATOR_SMS_BODY, and invalid values fail before the device gets touched.

Through the bars: port forwarding on loopback only

make forward ANDROID_LAB_FORWARD_HOST_PORT=18080 ANDROID_LAB_FORWARD_DEVICE_PORT=8080
make unforward

Something listening inside the phone on 8080 shows up on 127.0.0.1:18080 on the host. There are two tunnels: the first defaults to 19001 on both ends, and a second TCP one defaults to 19002. Forwarded ports and the VNC viewer bind loopback only. Nothing gets exposed to your LAN because you fat-fingered a bind address.

One enclosure per exhibit: workspaces and the Docker socket

This is the part people usually fuck up with Docker-in-Docker, so here’s who gets the keys to what:

  • Builds, lint and fixture tests never get the Docker socket. Gradle runs mount the project at /work and that’s all they see.
  • Emulator management does get the socket, because it has to create sibling containers. The controller mounts your workspace at its real host path, so the bind mounts it hands to sibling containers resolve on the host, even when the controller itself is running in a container.
  • Every workspace is its own Compose project, named after a hash of the workspace path. Two Android projects, two separate phones, no collisions. ANDROID_LAB_COMPOSE_PROJECT overrides it if you deliberately want to share.
  • The caller’s home directory is never bound. Phone data, ADB keys, forwarding metadata and artifacts live in .android-lab/shared/.

Expanding the habitat without knocking down walls

Need extra tools in the build image? Don’t fork it, derive it:

FROM android-lab:0.13.0
# Install the extra project-specific tools here.

The derived image keeps the android-lab command. Point DEV_IMAGE at it when using Make, or ANDROID_LAB_DEV_IMAGE for direct lifecycle calls, and helper containers use your tools too.

App-specific orchestration goes in a product extension. Set ANDROID_LAB_EXTENSION=scripts/product-lab.sh and the controller sources that script, but only if it resolves inside the mounted workspace. It defines android_lab_extension_command and can call the generic start, forward, compose and run_device_operation functions. This is trusted project code by design, not a sandbox, and the docs say so plainly. Resets, control clients, package names: all of that belongs in your app’s repo, not in the lab image. That’s exactly how Dikciz Launcher talks to it now from its own separate checkout.

Trained keepers: skills for the agent doing half the clicking

The repo ships two agent skills in .agents/skills/:

  • android-lab: image setup, Docker builds, emulator operations, UI inspection and port forwarding.
  • android-lab-kotlin: Kotlin, Java and Gradle work through the lab toolchain.

Copy both whole directories into any Android project’s .agents/skills/ and your coding agent knows how to build, boot, read the layout JSON and tap things, while being told flat out that host ADB, USB and physical devices are off limits, and that stopping the stack does not mean it gets to wipe your saved phone. Run that agent inside aicodebox or claudebox with the host Docker socket mounted, and the whole Android dev loop happens without a single SDK byte touching your host.

Safety inspection: rattling the bars on purpose

make lint
make test
make test-emulator
make test-forward

make test covers the embedded CLI, the Gradle project boundaries and the rendered Compose config. The live checks need KVM: they start the lab, poke it, and stop it again, leaving the phone disk alone. There’s also make test-vnc for the viewer.

Visiting hours

Linux x86_64 with KVM only, so Mac people can go cry into their M-series. Code’s at github.com/psyb0t/android-lab.

Enough zoo talk: it’s two containers and a Makefile, accept Google’s license like an adult and stop installing Android Studio.