Das Android SDK ist ein wildes Tier. Lass es frei in deiner Wohnung rumlaufen und es markiert überall sein Revier: ein SDK, ein Haufen Build Tools, ein Emulator, ein System-Image, ein JDK, das ihm lieber ist als deins, ein Verzeichnis ~/.android voller Keys und AVD-Configs, ein ~/.gradle-Cache von der Größe eines kleinen Landes und irgendein Hintergrund-Daemon, der noch lange RAM frisst, nachdem du das Ding längst zugemacht hast. Sechs Monate später stecken drei SDK-Versionen in den Wänden, zwei Emulatoren booten nicht mehr, und du hast keinen blassen Schimmer, welchen davon dein Build eigentlich benutzt.
So ein Tier zähmst du nicht. Du baust ihm ein Gehege, beobachtest es durch die Scheibe und fütterst es durch die Gitterstäbe.
Ich habe am Dikciz Launcher gebaut, einer Homescreen-App für Android, und ich habe mich geweigert, das Biest auf meine Maschine zu lassen. Also sind die Build-Toolchain und der Emulator in Docker gewandert. Dann sind dem Käfig ein Screenshot-Befehl, ein UI-Dump, Port-Forwarding und ein paar Dutzend Geräte-Fixtures gewachsen, und irgendwann war klar, dass das ein eigenes Ding ist und kein Teil eines Launchers. Also habe ich es in ein eigenes Repo rausgerissen: android-lab. Android-App bauen, Handy booten, in seiner UI rumstochern. Dein Host kriegt Docker, Make, Git und /dev/kvm. Sonst kommt nichts raus.
Beton gießen: zwei Images, gebaut auf deinem eigenen Daemon
Das Gehege besteht aus zwei Docker-Images, beide lokal gebaut. Nichts wird jemals in irgendeine Registry gepusht, weil diese Images SDK-Artefakte von Google enthalten und ich den Scheiß nicht weiterverteile. Du liest die Android-SDK-Lizenz, akzeptierst sie ausdrücklich und baust:
git clone https://github.com/psyb0t/android-lab.git
cd android-lab
ANDROID_LAB_ACCEPT_ANDROID_LICENSES=yes make accept-licenses
make buildAm Ende hast du zwei Tags, beide aus der Datei VERSION abgeleitet:
android-lab:0.13.0
android-lab:0.13.0-emulator-api36Das Tool-Image enthält ADB, Java 17, die Plattform und Build Tools für API 36, Python, pytest, Shell-Werkzeug, Docker und Compose, noVNC und die Lab-Skripte, alles eingebettet unter /opt/android-lab, mit einem android-lab-Befehl im PATH. Das Emulator-Image enthält den Emulator, ein Google-APIs-x86_64-System-Image und dessen Display-Stack, oben auf einem per Digest gepinnten ubuntu:24.04.
Sein eigenes Futter mitbringen darf das Tier auch nicht. resolve_android_downloads.py liest beim Build die Repository-Metadaten direkt von Google, zieht daraus den SHA-1 jedes Artefakts und bricht ab, wenn das, was zurückkommt, keine 40-stellige Hex-Prüfsumme ist. Baust du nach einem Update des Checkouts neu, verwendet Docker jede unveränderte Layer wieder, du kassierst also nicht jedes Mal die volle Download-Strafe.
Fütterungszeit: ein APK ohne ein einziges Android-Teil auf dem Host
Aus jedem beliebigen Android-Projekt auf deiner Maschine:
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 gradleDas APK landet im ganz normalen Build-Output deines Projekts. Es läuft mit deiner UID und GID, du hast also keine Root-eigenen Haufen im Quellbaum liegen, die du später mit sudo rm wegmachen musst.
Der Tierpfleger ist paranoid, was durch die Gitterstäbe geht, und jeder dieser Punkte ist ein echter Fehlerpfad in gradle-project.sh:
ANDROID_PROJECTmuss ein relatives Verzeichnis innerhalb des Workspace sein. Kein../../-Traversal, keine Symlinks, die ausbüxen.- Das Projekt braucht einen ausführbaren Gradle Wrapper samt Properties-Datei.
- Dieser Wrapper muss
distributionSha256Sumpinnen. Keine Prüfsumme, kein Build. Ein Gradle Wrapper, der runterlädt, was man ihm sagt, ist Remote Code Execution mit Extraschritten. GRADLE_TASKnimmt genau einen voll qualifizierten Task. Keinen Shell-String, keine Liste von irgendwas.ANDROID_LAB_GRADLE_MAX_WORKERSakzeptiert 1 bis 10.
Gradle-Caches und die Debug-Signaturidentität landen im projekteigenen Verzeichnis .android-lab/, nicht in deinem Home. Der Signaturschlüssel ist stabil, ein neu gebautes APK installiert sich also über das alte, statt dass Android wegen eines Signature Mismatch rumbrüllt. Pack .android-lab/ in deine .gitignore und lösch es nie, um zu “upgraden”: Da drin liegt auch dein gespeichertes Handy.
Die Portionen sind rationiert. Tool-Container kriegen so viele CPUs, wie der Docker-Host hat, aber höchstens 10, dazu 8g Speicher, Swap ebenfalls bei 8g gedeckelt, und ein Limit von 2048 PIDs, und die Gradle-Worker richten sich nach dem CPU-Limit. Willst du deine Maschine zurück, während kompiliert wird? make build ANDROID_LAB_TOOL_CPU_LIMIT=2 ANDROID_LAB_GRADLE_MAX_WORKERS=2. Die Kurzziele app-build, app-test und app-lint entsprechen :app:assembleDebug, :app:testDebugUnitTest und :app:lintDebug.
Hinter der Scheibe: ein Handy, das du im Browser-Tab beobachtest
make run
make device-info
make screenshot
make ui-layout
make stopmake run bootet ein persistentes emuliertes Handy: 1080×2400, vier virtuelle Kerne, 4 GiB Gast-RAM, KVM-beschleunigt. Dann öffnest du http://127.0.0.1:61326/vnc.html?autoconnect=true&resize=scale und da ist dein Handy, in einem Browser-Tab, über noVNC. Das ist die Besucherscheibe. make stop reißt die Container und Netzwerke dieses Workspace ab, lässt aber die Handy-Disk in Ruhe, deine installierten Apps und Einstellungen sind beim nächsten Boot also noch da. make restart startet das Handy neu, ohne irgendwas zurückzusetzen.
Das Gehege selbst ist eine Compose-Datei mit sechs Services: keygen erzeugt die ADB-Keys, emulator betreibt das Handy, adb-bridge, forward und web-vnc kümmern sich um den Zugang, und lab führt die Geräteoperationen aus. Jeder davon wirft alle Capabilities ab, setzt no-new-privileges, weigert sich, Images zu pullen (pull_policy: never), und bekommt ein PID- und Speicherlimit. Der keygen-Service läuft mit network_mode: none und schreibgeschütztem Root, weil ein Container, dessen einziger Job das Schreiben eines Schlüsselpaars ist, im Internet nichts verloren hat. Der Emulator ist auf 4 CPUs und 8g gedeckelt, und sein privater ADB-Endpoint ist emulator:5556 in einem internen Netzwerk. Das ist das einzige Gerät, mit dem überhaupt irgendeine Operation redet. Nicht dein Host-ADB, nicht USB, nicht das echte Handy in deiner Hosentasche.
Das Klemmbrett des Pflegers: UI-Zustand als JSON
Durch die Scheibe glotzen funktioniert für Menschen. Für alles Automatisierte ist ein PNG nutzlos, und rohes UIAutomator-XML ist ein verdammter Sumpf. Also dumpt make ui-layout die Hierarchie und jagt sie durch uiautomator_to_json.py, das jeden Knoten zu kompaktem JSON plattklopft: className, resourceId, text, contentDesc, eine interactions-Liste (clickable, scrollable, longClickable und Konsorten), eine state-Liste (checked, enabled, focused, selected), visibleToUser, die Bounds und ein vorberechnetes center x/y. Das Letzte ist der ganze Witz: Du liest das JSON, findest den Button, tippst in seine Mitte. Kein Pixelraten.
Screenshots, UI-XML und das Layout-JSON landen alle in .android-lab/shared/artifacts/.
Beschäftigung: ein komplettes Fake-Leben für das Handy
Tiere in Gefangenschaft brauchen Beschäftigung, sonst zeigen sie dir nie, wie sie sich in freier Wildbahn verhalten. Das Makefile definiert 36 Geräte-Targets, und make help listet sie auf. Die langweiligen Grundlagen sind dabei: apk-install, app-start, app-stop, app-current, device-shell, file-push, file-pull, home-role-set, um deine App zum Standard-Launcher zu machen, accessibility-on und accessibility-off.
Dann gibt es die Emulator-Event-Fixtures, die existieren, weil ein Launcher auf die echte Welt reagieren muss und ich nicht mit einem Laptop draußen rumlaufen wollte, um den Scheiß zu testen. Du kannst einen GPS-Fix faken, Sensorwerte setzen, eine SMS von wem auch immer empfangen, Netzstrom, WLAN, Bluetooth, Thermalzustand, Power Save, Device Idle, Night Mode, Ausrichtung, Schriftgröße, Klingelmodus, den Notification-Policy-Zugriff und den Unterbrechungsfilter umschalten. Obendrauf gibt es Kalender-Fixtures zum Anlegen, Ändern und Löschen von Terminen und Kontakt-Fixtures zum Anlegen und Löschen von Leuten. Alles nimmt benannte Make-Variablen wie EMULATOR_SMS_SENDER und EMULATOR_SMS_BODY, und ungültige Werte scheitern, bevor das Gerät angefasst wird.
Durch die Gitterstäbe: Port-Forwarding nur auf Loopback
make forward ANDROID_LAB_FORWARD_HOST_PORT=18080 ANDROID_LAB_FORWARD_DEVICE_PORT=8080
make unforwardWas im Handy auf 8080 lauscht, taucht auf dem Host unter 127.0.0.1:18080 auf. Es gibt zwei Tunnel: Der erste steht standardmäßig an beiden Enden auf 19001, ein zweiter TCP-Tunnel standardmäßig auf 19002. Weitergeleitete Ports und der VNC-Viewer binden nur an Loopback. Nichts landet offen in deinem LAN, nur weil du dich bei einer Bind-Adresse vertippt hast.
Ein Gehege pro Tier: Workspaces und der Docker-Socket
Das ist der Teil, den die Leute bei Docker-in-Docker normalerweise verkacken, also hier, wer die Schlüssel wofür bekommt:
- Builds, Lint und Fixture-Tests kriegen niemals den Docker-Socket. Gradle-Läufe mounten das Projekt unter
/work, und das ist alles, was sie sehen. - Die Emulator-Verwaltung kriegt den Socket sehr wohl, weil sie Geschwister-Container anlegen muss. Der Controller mountet deinen Workspace unter seinem echten Host-Pfad, damit die Bind Mounts, die er an Geschwister-Container weitergibt, auf dem Host aufgelöst werden, selbst wenn der Controller selbst in einem Container läuft.
- Jeder Workspace ist sein eigenes Compose-Projekt, benannt nach einem Hash des Workspace-Pfads. Zwei Android-Projekte, zwei getrennte Handys, keine Kollisionen.
ANDROID_LAB_COMPOSE_PROJECTüberschreibt den Namen, wenn du absichtlich dasselbe Handy teilen willst. - Das Home-Verzeichnis des Aufrufers wird nie gemountet. Handydaten, ADB-Keys, Forwarding-Metadaten und Artefakte liegen in
.android-lab/shared/.
Das Gehege erweitern, ohne Wände einzureißen
Brauchst du zusätzliche Tools im Build-Image? Nicht forken, ableiten:
FROM android-lab:0.13.0
# Install the extra project-specific tools here.Das abgeleitete Image behält den android-lab-Befehl. Lass DEV_IMAGE darauf zeigen, wenn du Make benutzt, oder ANDROID_LAB_DEV_IMAGE für direkte Lifecycle-Aufrufe, und die Helper-Container benutzen deine Tools ebenfalls.
App-spezifische Orchestrierung gehört in eine Produkt-Extension. Setz ANDROID_LAB_EXTENSION=scripts/product-lab.sh und der Controller sourct dieses Skript, aber nur, wenn es sich innerhalb des gemounteten Workspace auflöst. Das Skript definiert android_lab_extension_command und kann die generischen Funktionen start, forward, compose und run_device_operation aufrufen. Das ist absichtlich vertrauenswürdiger Projektcode, keine Sandbox, und die Doku sagt das ganz offen. Resets, Steuer-Clients, Paketnamen: All das gehört ins Repo deiner App, nicht ins Lab-Image. Genau so redet Dikciz Launcher jetzt mit dem Lab, aus seinem eigenen, separaten Checkout.
Ausgebildete Pfleger: Skills für den Agenten, der die Hälfte der Klicks macht
Das Repo bringt zwei Agent-Skills in .agents/skills/ mit:
- android-lab: Image-Setup, Docker-Builds, Emulator-Operationen, UI-Inspektion und Port-Forwarding.
- android-lab-kotlin: Kotlin-, Java- und Gradle-Arbeit über die Toolchain des Labs.
Kopier beide Verzeichnisse komplett in das .agents/skills/ eines beliebigen Android-Projekts, und dein Coding-Agent weiß, wie er baut, das Handy bootet, das Layout-JSON liest und Sachen antippt, während ihm unmissverständlich gesagt wird, dass Host-ADB, USB und physische Geräte tabu sind und dass das Stoppen des Stacks nicht heißt, dass er dein gespeichertes Handy plattmachen darf. Lass diesen Agenten in aicodebox oder claudebox laufen, mit dem Docker-Socket des Hosts gemountet, und die ganze Android-Dev-Schleife passiert, ohne dass ein einziges SDK-Byte deinen Host berührt.
Sicherheitsinspektion: absichtlich an den Gitterstäben rütteln
make lint
make test
make test-emulator
make test-forwardmake test deckt die eingebettete CLI, die Grenzen des Gradle-Projekts und die gerenderte Compose-Config ab. Die Live-Checks brauchen KVM: Sie starten das Lab, stupsen es an und stoppen es wieder, ohne die Handy-Disk anzurühren. Für den Viewer gibt es außerdem make test-vnc.
Besuchszeiten
Nur Linux x86_64 mit KVM, die Mac-Leute können also in ihren M-Chip heulen. Der Code liegt auf github.com/psyb0t/android-lab.
Genug Zoogelaber: Es sind zwei Container und ein Makefile, akzeptier die Google-Lizenz wie ein Erwachsener und hör auf, Android Studio zu installieren.