Un enclos de zoo pour le SDK Android : android-lab

Le SDK Android, c’est une bête sauvage. Laisse-le traîner chez toi et il pisse partout pour marquer son territoire : un SDK, une pile de build tools, un émulateur, une image système, un JDK qu’il préfère au tien, un répertoire ~/.android bourré de clés et de configs AVD, un cache ~/.gradle de la taille d’un petit pays, et un daemon en arrière-plan qui continue de bouffer ta RAM bien après que t’as fermé le truc. Six mois plus tard, t’as trois versions du SDK planquées dans les murs, deux émulateurs qui refusent de démarrer, et aucune idée de celui que ton build utilise vraiment.

Une bête pareille, ça ne s’apprivoise pas. Tu lui construis un enclos, tu la regardes à travers la vitre et tu la nourris entre les barreaux.

Je bossais sur Dikciz Launcher, une app d’écran d’accueil Android, et il était hors de question que je laisse la bête entrer dans ma machine. Donc la toolchain de build et l’émulateur sont partis dans Docker. Puis la cage s’est vu pousser une commande de screenshot, un dump de l’UI, du port forwarding, quelques dizaines de fixtures pour l’appareil, et à un moment c’était évident que c’était un projet à part entière, pas un morceau de launcher. Alors je l’ai arraché dans son propre repo : android-lab. Tu builds une app Android, tu démarres un téléphone, tu tripotes son UI. Ton host a droit à Docker, Make, Git et /dev/kvm. Rien d’autre ne s’échappe.

On coule le béton : deux images, buildées sur ton propre daemon

L’enclos, c’est deux images Docker, toutes les deux buildées en local. Rien n’est poussé sur un registry, jamais, parce que ces images embarquent des artefacts du SDK de Google et je ne vais pas redistribuer cette merde. Tu lis la licence du SDK Android, tu l’acceptes explicitement, tu builds :

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

Tu te retrouves avec deux tags, tous les deux dérivés du fichier VERSION :

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

L’image d’outils contient ADB, Java 17, la plateforme et les build tools de l’API 36, Python, pytest, de l’outillage shell, Docker et Compose, noVNC et les scripts du lab, le tout embarqué dans /opt/android-lab avec une commande android-lab dans le PATH. L’image émulateur contient l’émulateur, une image système Google APIs x86_64 et sa pile d’affichage, par-dessus une ubuntu:24.04 épinglée par digest.

La bête n’a pas non plus le droit d’apporter sa propre bouffe. resolve_android_downloads.py lit au build les métadonnées du dépôt de Google lui-même, en extrait le SHA-1 de chaque artefact et refuse d’aller plus loin si ce qui revient n’est pas un checksum hexa de 40 caractères. Tu rebuildes après avoir mis à jour ton checkout et Docker réutilise chaque layer inchangé, donc tu ne te tapes pas la punition du téléchargement complet à chaque fois.

L’heure du repas : un APK sans le moindre bout d’Android sur le host

Depuis n’importe quel projet Android sur ta 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

L’APK atterrit dans la sortie de build normale de ton projet. Ça tourne avec ton UID et ton GID, donc tu ne te retrouves pas avec des crottes appartenant à root dans tes sources, à virer plus tard avec un sudo rm.

Le soigneur est parano sur ce qui passe entre les barreaux, et chacun de ces points est un vrai chemin d’erreur dans gradle-project.sh :

  • ANDROID_PROJECT doit être un répertoire relatif à l’intérieur du workspace. Pas de traversée en ../../, pas de symlinks qui se font la malle.
  • Le projet doit avoir un Gradle wrapper exécutable et son fichier de properties.
  • Ce wrapper doit épingler distributionSha256Sum. Pas de checksum, pas de build. Un Gradle wrapper qui télécharge tout ce qu’on lui dit, c’est de l’exécution de code à distance avec des étapes en plus.
  • GRADLE_TASK prend une seule tâche pleinement qualifiée. Pas une chaîne shell, pas une liste de n’importe quoi.
  • ANDROID_LAB_GRADLE_MAX_WORKERS accepte de 1 à 10.

Les caches Gradle et l’identité de signature debug vont dans le répertoire .android-lab/ du projet, pas dans ton home. La clé de signature est stable, donc un APK rebuildé s’installe par-dessus l’ancien au lieu de laisser Android hurler à la signature qui ne correspond pas. Mets .android-lab/ dans ton .gitignore et ne le supprime jamais pour “upgrader” : il contient aussi ton téléphone sauvegardé.

Les rations sont comptées. Les conteneurs d’outils ont droit au nombre de CPU du host Docker plafonné à 10, à 8g de mémoire avec le swap plafonné aux mêmes 8g, et à une limite de 2048 PID, et les workers Gradle suivent la limite CPU. Tu veux récupérer ta machine pendant que ça compile ? make build ANDROID_LAB_TOOL_CPU_LIMIT=2 ANDROID_LAB_GRADLE_MAX_WORKERS=2. Les cibles-raccourcis app-build, app-test et app-lint correspondent à :app:assembleDebug, :app:testDebugUnitTest et :app:lintDebug.

Derrière la vitre : un téléphone que tu observes depuis un onglet

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

make run démarre un téléphone émulé persistant : 1080×2400, quatre cœurs virtuels, 4 GiB de RAM côté invité, accéléré par KVM. Ensuite tu ouvres http://127.0.0.1:61326/vnc.html?autoconnect=true&resize=scale et ton téléphone est là, dans un onglet du navigateur, via noVNC. C’est la vitre pour les visiteurs. make stop démonte les conteneurs et les réseaux de ce workspace mais ne touche pas au disque du téléphone, donc tes apps installées et tes réglages sont toujours là au prochain démarrage. make restart redémarre le téléphone sans rien réinitialiser.

L’enclos lui-même, c’est un fichier Compose avec six services : keygen génère les clés ADB, emulator fait tourner le téléphone, adb-bridge, forward et web-vnc gèrent l’accès, et lab exécute les opérations sur l’appareil. Chacun d’eux lâche toutes ses capabilities, active no-new-privileges, refuse de puller des images (pull_policy: never) et reçoit une limite de PID et de mémoire. Le service keygen tourne avec network_mode: none et une racine en lecture seule, parce qu’un conteneur dont le seul boulot est d’écrire une paire de clés n’a rien à foutre sur internet. L’émulateur est plafonné à 4 CPU et 8g, et son endpoint ADB privé est emulator:5556 sur un réseau interne. Aucune opération ne parle jamais à un autre appareil. Pas l’ADB de ton host, pas l’USB, pas le vrai téléphone dans ta poche.

Le carnet du soigneur : l’état de l’UI en JSON

Mater à travers la vitre, ça marche pour les humains. Pour tout ce qui est automatisé, un PNG ne sert à rien et le XML brut d’UIAutomator est un putain de marécage. Donc make ui-layout dumpe la hiérarchie et la passe dans uiautomator_to_json.py, qui aplatit chaque nœud en JSON compact : className, resourceId, text, contentDesc, une liste interactions (clickable, scrollable, longClickable et toute la bande), une liste state (checked, enabled, focused, selected), visibleToUser, les bounds, et un center x/y précalculé. Ce dernier, c’est tout l’intérêt : tu lis le JSON, tu trouves le bouton, tu tapes en son centre. Pas de devinette au pixel près.

Les screenshots, le XML de l’UI et le JSON du layout atterrissent tous dans .android-lab/shared/artifacts/.

Enrichissement : une vie entière bidonnée pour le téléphone

Les animaux en captivité ont besoin de stimulation, sinon ils ne te montrent jamais comment ils se comportent dans la nature. Le Makefile définit 36 cibles pour l’appareil, et make help les liste. Les essentiels chiants sont là : apk-install, app-start, app-stop, app-current, device-shell, file-push, file-pull, home-role-set pour faire de ton app le launcher par défaut, accessibility-on et accessibility-off.

Ensuite il y a les fixtures d’événements pour l’émulateur, qui existent parce qu’un launcher doit réagir au monde réel et que je n’allais pas me balader dehors avec un laptop pour tester cette connerie. Tu peux simuler une position GPS, régler des valeurs de capteurs, recevoir un SMS de qui tu veux, basculer l’alimentation secteur, le Wi-Fi, le Bluetooth, l’état thermique, le power save, le device idle, le night mode, l’orientation, l’échelle de police, le mode sonnerie, l’accès à la notification policy et le filtre d’interruptions. Par-dessus, t’as des fixtures de calendrier pour créer, modifier et supprimer des événements, et des fixtures de contacts pour créer et supprimer des gens. Tout prend des variables Make nommées comme EMULATOR_SMS_SENDER et EMULATOR_SMS_BODY, et les valeurs invalides échouent avant qu’on touche à l’appareil.

Entre les barreaux : du port forwarding sur loopback uniquement

make forward ANDROID_LAB_FORWARD_HOST_PORT=18080 ANDROID_LAB_FORWARD_DEVICE_PORT=8080
make unforward

Un truc qui écoute dans le téléphone sur le 8080 apparaît sur le host en 127.0.0.1:18080. Il y a deux tunnels : le premier vaut 19001 par défaut aux deux bouts, et un second, en TCP, vaut 19002 par défaut. Les ports forwardés et le viewer VNC n’écoutent que sur loopback. Rien ne se retrouve exposé sur ton LAN parce que t’as foiré une adresse de bind avec tes gros doigts.

Un enclos par pensionnaire : les workspaces et le socket Docker

C’est la partie que les gens foutent en l’air d’habitude avec du Docker-in-Docker, alors voilà qui a les clés de quoi :

  • Les builds, le lint et les tests de fixtures n’ont jamais le socket Docker. Les exécutions Gradle montent le projet sur /work et c’est tout ce qu’elles voient.
  • La gestion de l’émulateur, elle, a le socket, parce qu’elle doit créer des conteneurs frères. Le contrôleur monte ton workspace à son vrai chemin sur le host, pour que les bind mounts qu’il passe aux conteneurs frères se résolvent sur le host, même quand le contrôleur tourne lui-même dans un conteneur.
  • Chaque workspace est son propre projet Compose, nommé d’après un hash du chemin du workspace. Deux projets Android, deux téléphones séparés, zéro collision. ANDROID_LAB_COMPOSE_PROJECT écrase ce nom si tu veux volontairement partager le même téléphone.
  • Le répertoire home de l’appelant n’est jamais monté. Les données du téléphone, les clés ADB, les métadonnées de forwarding et les artefacts vivent dans .android-lab/shared/.

Agrandir l’habitat sans abattre les murs

Besoin d’outils en plus dans l’image de build ? Ne la forke pas, dérive-la :

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

L’image dérivée garde la commande android-lab. Fais pointer DEV_IMAGE dessus quand tu passes par Make, ou ANDROID_LAB_DEV_IMAGE pour les appels de cycle de vie directs, et les conteneurs auxiliaires utilisent tes outils aussi.

L’orchestration propre à l’app va dans une extension produit. Tu mets ANDROID_LAB_EXTENSION=scripts/product-lab.sh et le contrôleur source ce script, mais seulement s’il se résout à l’intérieur du workspace monté. Le script définit android_lab_extension_command et peut appeler les fonctions génériques start, forward, compose et run_device_operation. C’est du code de projet de confiance, par design, pas un sandbox, et la doc le dit sans détour. Les resets, les clients de contrôle, les noms de packages : tout ça appartient au repo de ton app, pas à l’image du lab. C’est exactement comme ça que Dikciz Launcher parle au lab aujourd’hui, depuis son propre checkout séparé.

Des soigneurs dressés : des skills pour l’agent qui fait la moitié des clics

Le repo embarque deux skills d’agent dans .agents/skills/ :

  • android-lab : setup de l’image, builds Docker, opérations sur l’émulateur, inspection de l’UI et port forwarding.
  • android-lab-kotlin : le boulot Kotlin, Java et Gradle via la toolchain du lab.

Copie les deux répertoires en entier dans le .agents/skills/ de n’importe quel projet Android et ton agent de code sait builder, démarrer le téléphone, lire le JSON du layout et taper sur des trucs, tout en se faisant dire cash que l’ADB du host, l’USB et les appareils physiques sont interdits, et qu’arrêter la stack ne lui donne pas le droit d’effacer ton téléphone sauvegardé. Fais tourner cet agent dans aicodebox ou claudebox avec le socket Docker du host monté, et toute la boucle de dev Android se déroule sans qu’un seul octet de SDK touche ton host.

Inspection de sécurité : secouer les barreaux exprès

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

make test couvre la CLI embarquée, les frontières du projet Gradle et la config Compose rendue. Les checks live ont besoin de KVM : ils démarrent le lab, le titillent et l’arrêtent à nouveau, sans toucher au disque du téléphone. Il y a aussi make test-vnc pour le viewer.

Horaires de visite

Linux x86_64 avec KVM uniquement, donc les gens sur Mac peuvent aller pleurer dans leur puce M. Le code est sur github.com/psyb0t/android-lab.

Assez parlé de zoo : c’est deux conteneurs et un Makefile, accepte la licence de Google comme un grand et arrête d’installer Android Studio.