De Android SDK is een wild beest. Laat hem los in je huis en hij markeert overal zijn territorium: een SDK, een stapel build tools, een emulator, een system image, een JDK die hij liever heeft dan de jouwe, een directory ~/.android vol keys en AVD-configs, een ~/.gradle-cache zo groot als een klein land en een of andere achtergrond-daemon die nog lang RAM blijft vreten nadat je het ding allang hebt dichtgeklikt. Zes maanden later zitten er drie SDK-versies in de muren, twee emulators die niet meer booten, en je hebt geen flauw idee welke ervan je build eigenlijk gebruikt.
Zo’n beest tem je niet. Je bouwt er een verblijf voor, je kijkt naar hem door het glas en je voert hem tussen de tralies door.
Ik was Dikciz Launcher aan het bouwen, een homescreen-app voor Android, en ik weigerde het beest op mijn machine toe te laten. Dus gingen de build-toolchain en de emulator Docker in. Daarna groeiden er aan de kooi een screenshot-commando, een UI-dump, port forwarding en een paar dozijn device-fixtures, en op een gegeven moment was het overduidelijk een ding op zich en geen onderdeel van een launcher. Dus heb ik het eruit gerukt en in een eigen repo gezet: android-lab. Android-app bouwen, telefoon booten, in de UI porren. Je host krijgt Docker, Make, Git en /dev/kvm. Verder komt er niks naar buiten.
Beton storten: twee images, gebouwd op je eigen daemon
Het verblijf bestaat uit twee Docker-images, allebei lokaal gebouwd. Er wordt nooit iets naar een registry gepusht, want deze images bevatten SDK-artefacten van Google en ik ga die troep niet herdistribueren. Je leest de Android SDK-licentie, accepteert hem expliciet en bouwt:
git clone https://github.com/psyb0t/android-lab.git
cd android-lab
ANDROID_LAB_ACCEPT_ANDROID_LICENSES=yes make accept-licenses
make buildDaarna heb je twee tags, allebei afgeleid van het bestand VERSION:
android-lab:0.13.0
android-lab:0.13.0-emulator-api36De tool-image bevat ADB, Java 17, het platform en de build tools voor API 36, Python, pytest, shell-tooling, Docker en Compose, noVNC en de lab-scripts, allemaal ingebed in /opt/android-lab met een android-lab-commando op het PATH. De emulator-image bevat de emulator, een Google APIs x86_64 system image en de bijbehorende display-stack, bovenop een op digest vastgepinde ubuntu:24.04.
Zijn eigen voer meenemen mag het beest ook niet. resolve_android_downloads.py leest tijdens de build de repository-metadata van Google zelf, haalt daar de SHA-1 van elk artefact uit en weigert verder te gaan als wat er terugkomt geen hex-checksum van 40 tekens is. Bouw je opnieuw na een update van je checkout, dan hergebruikt Docker elke ongewijzigde layer, dus je krijgt niet elke keer de volledige downloadstraf.
Voedertijd: een APK zonder ook maar één Android-ding op de host
Vanuit elk Android-project op je 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 gradleDe APK komt in de normale build-output van je project terecht. Hij draait onder jouw UID en GID, dus je zit niet met keutels van root in je source tree die je later met sudo rm moet opruimen.
De oppasser is paranoïde over wat er tussen de tralies door gaat, en elk van deze punten is een echt foutpad in gradle-project.sh:
ANDROID_PROJECTmoet een relatieve directory binnen de workspace zijn. Geen../../-traversal, geen symlinks die ontsnappen.- Het project heeft een uitvoerbare Gradle wrapper nodig plus het bijbehorende properties-bestand.
- Die wrapper moet
distributionSha256Sumvastpinnen. Geen checksum, geen build. Een Gradle wrapper die downloadt wat hem ook maar opgedragen wordt, is remote code execution met extra stappen. GRADLE_TASKneemt precies één volledig gekwalificeerde task. Geen shell-string, geen lijst met wat dan ook.ANDROID_LAB_GRADLE_MAX_WORKERSaccepteert 1 tot en met 10.
Gradle-caches en de debug-signing-identiteit gaan naar de eigen directory .android-lab/ van het project, niet naar je home. De signing key is stabiel, dus een opnieuw gebouwde APK installeert over de oude heen in plaats van dat Android gaat krijsen over een signature mismatch. Zet .android-lab/ in je .gitignore en verwijder hem nooit om te “upgraden”: daar staat ook je opgeslagen telefoon in.
De porties zijn op rantsoen. Tool-containers krijgen het CPU-aantal van de Docker-host met een plafond van 10, 8g geheugen met swap afgetopt op diezelfde 8g en een limiet van 2048 PID’s, en de Gradle-workers volgen de CPU-limiet. Wil je je machine terug terwijl hij compileert? make build ANDROID_LAB_TOOL_CPU_LIMIT=2 ANDROID_LAB_GRADLE_MAX_WORKERS=2. De shortcut-targets app-build, app-test en app-lint komen overeen met :app:assembleDebug, :app:testDebugUnitTest en :app:lintDebug.
Achter het glas: een telefoon die je vanuit een browsertab bekijkt
make run
make device-info
make screenshot
make ui-layout
make stopmake run boot een persistente geëmuleerde telefoon: 1080×2400, vier virtuele cores, 4 GiB guest-RAM, versneld met KVM. Dan open je http://127.0.0.1:61326/vnc.html?autoconnect=true&resize=scale en daar is je telefoon, in een browsertab, via noVNC. Dat is het kijkvenster voor de bezoekers. make stop breekt de containers en netwerken van deze workspace af maar blijft van de telefoondisk af, dus je geïnstalleerde apps en instellingen staan er de volgende boot nog. make restart herstart de telefoon zonder iets te resetten.
Het verblijf zelf is een Compose-bestand met zes services: keygen genereert de ADB-keys, emulator draait de telefoon, adb-bridge, forward en web-vnc regelen de toegang, en lab voert de device-operaties uit. Elk daarvan dropt alle capabilities, zet no-new-privileges, weigert images te pullen (pull_policy: never) en krijgt een PID- en geheugenlimiet. De keygen-service draait met network_mode: none en een read-only root, want een container die alleen maar één keypair hoeft weg te schrijven, heeft op internet niks te zoeken. De emulator zit op maximaal 4 CPU’s en 8g, en zijn private ADB-endpoint is emulator:5556 op een intern netwerk. Dat is het enige device waar een operatie ooit mee praat. Niet je host-ADB, niet USB, niet de echte telefoon in je broekzak.
Het klembord van de oppasser: UI-state als JSON
Door het glas staren werkt voor mensen. Voor alles wat geautomatiseerd is, heb je niks aan een PNG en is ruwe UIAutomator-XML een kutmoeras. Dus make ui-layout dumpt de hiërarchie en haalt die door uiautomator_to_json.py, dat elke node platslaat tot compacte JSON: className, resourceId, text, contentDesc, een interactions-lijst (clickable, scrollable, longClickable en consorten), een state-lijst (checked, enabled, focused, selected), visibleToUser, de bounds en een vooraf berekende center x/y. Die laatste is waar het allemaal om draait: je leest de JSON, vindt de knop, tikt in het midden ervan. Geen pixelgegok.
Screenshots, UI-XML en de layout-JSON komen allemaal terecht in .android-lab/shared/artifacts/.
Verrijking: een heel nepleven voor de telefoon
Dieren in gevangenschap hebben prikkels nodig, anders laten ze je nooit zien hoe ze zich in het wild gedragen. De Makefile definieert 36 device-targets, en make help zet ze op een rij. De saaie basics zitten erin: apk-install, app-start, app-stop, app-current, device-shell, file-push, file-pull, home-role-set om je app de standaard-launcher te maken, accessibility-on en accessibility-off.
Dan heb je de event-fixtures voor de emulator, die bestaan omdat een launcher op de echte wereld moet reageren en ik echt niet met een laptop buiten ging rondlopen om die shit te testen. Je kunt een GPS-positie faken, sensorwaarden zetten, een sms ontvangen van wie je maar wilt, en netstroom, wifi, Bluetooth, thermische status, power save, device idle, night mode, oriëntatie, lettergrootte, belmodus, toegang tot de notification policy en het onderbrekingsfilter omzetten. Daarbovenop zijn er agenda-fixtures om afspraken aan te maken, bij te werken en te verwijderen, en contact-fixtures om mensen aan te maken en te verwijderen. Alles neemt benoemde Make-variabelen zoals EMULATOR_SMS_SENDER en EMULATOR_SMS_BODY, en ongeldige waarden falen voordat het device wordt aangeraakt.
Tussen de tralies door: port forwarding alleen op loopback
make forward ANDROID_LAB_FORWARD_HOST_PORT=18080 ANDROID_LAB_FORWARD_DEVICE_PORT=8080
make unforwardIets wat in de telefoon op 8080 luistert, verschijnt op de host op 127.0.0.1:18080. Er zijn twee tunnels: de eerste staat standaard op 19001 aan beide kanten, en een tweede TCP-tunnel staat standaard op 19002. Geforwarde poorten en de VNC-viewer binden alleen op loopback. Er komt niks open op je LAN te staan omdat je je vertikt hebt in een bind-adres.
Eén verblijf per dier: workspaces en de Docker-socket
Dit is het deel dat mensen meestal verkloten met Docker-in-Docker, dus zo zit het met wie de sleutels van wat krijgt:
- Builds, lint en fixture-tests krijgen nooit de Docker-socket. Gradle-runs mounten het project op
/worken dat is alles wat ze zien. - Het emulatorbeheer krijgt de socket wel, omdat het sibling-containers moet aanmaken. De controller mount je workspace op zijn echte hostpad, zodat de bind mounts die hij aan sibling-containers doorgeeft op de host resolven, zelfs als de controller zelf in een container draait.
- Elke workspace is een eigen Compose-project, vernoemd naar een hash van het workspace-pad. Twee Android-projecten, twee aparte telefoons, geen botsingen.
ANDROID_LAB_COMPOSE_PROJECToverschrijft die naam als je bewust dezelfde telefoon wilt delen. - De home-directory van de aanroeper wordt nooit gemount. Telefoondata, ADB-keys, forwarding-metadata en artefacten staan in
.android-lab/shared/.
Het leefgebied uitbreiden zonder muren te slopen
Extra tools nodig in de build-image? Niet forken, afleiden:
FROM android-lab:0.13.0
# Install the extra project-specific tools here.De afgeleide image houdt het android-lab-commando. Laat DEV_IMAGE ernaar wijzen als je Make gebruikt, of ANDROID_LAB_DEV_IMAGE voor directe lifecycle-aanroepen, en de helper-containers gebruiken jouw tools ook.
App-specifieke orkestratie hoort in een productextensie. Zet ANDROID_LAB_EXTENSION=scripts/product-lab.sh en de controller sourcet dat script, maar alleen als het binnen de gemounte workspace resolvet. Het script definieert android_lab_extension_command en kan de generieke functies start, forward, compose en run_device_operation aanroepen. Dit is bewust vertrouwde projectcode, geen sandbox, en de docs zeggen dat ronduit. Resets, control-clients, packagenamen: dat hoort allemaal in de repo van je app, niet in de lab-image. Precies zo praat Dikciz Launcher nu met het lab, vanuit zijn eigen aparte checkout.
Getrainde oppassers: skills voor de agent die de helft van het klikwerk doet
De repo levert twee agent-skills mee in .agents/skills/:
- android-lab: image-setup, Docker-builds, emulator-operaties, UI-inspectie en port forwarding.
- android-lab-kotlin: Kotlin-, Java- en Gradle-werk via de toolchain van het lab.
Kopieer beide directory’s in hun geheel naar de .agents/skills/ van een willekeurig Android-project en je coding-agent weet hoe hij moet bouwen, de telefoon booten, de layout-JSON lezen en dingen aantikken, terwijl hem botweg verteld wordt dat host-ADB, USB en fysieke devices verboden terrein zijn, en dat de stack stoppen niet betekent dat hij je opgeslagen telefoon mag wissen. Draai die agent in aicodebox of claudebox met de Docker-socket van de host gemount, en de hele Android-dev-loop gebeurt zonder dat er één SDK-byte je host aanraakt.
Veiligheidsinspectie: expres aan de tralies rammelen
make lint
make test
make test-emulator
make test-forwardmake test dekt de ingebedde CLI, de grenzen van het Gradle-project en de gerenderde Compose-config. De live-checks hebben KVM nodig: ze starten het lab, porren erin en zetten het weer stop, zonder de telefoondisk aan te raken. Er is ook nog make test-vnc voor de viewer.
Bezoekuren
Alleen Linux x86_64 met KVM, dus de Mac-mensen kunnen gaan janken in hun M-chip. De code staat op github.com/psyb0t/android-lab.
Genoeg dierentuingelul: het zijn twee containers en een Makefile, accepteer de licentie van Google als een volwassene en stop met Android Studio installeren.