Un recinto de zoo para el SDK de Android: android-lab

El SDK de Android es un animal salvaje. Déjalo suelto por casa y te mea todo para marcar territorio: un SDK, un montón de build tools, un emulador, una imagen de sistema, un JDK que le gusta más que el tuyo, un directorio ~/.android lleno de claves y configs de AVD, una caché ~/.gradle del tamaño de un país pequeño y algún daemon en segundo plano que sigue zampándose la RAM mucho después de que cerraste el chisme. Seis meses después tienes tres versiones del SDK metidas en las paredes, dos emuladores que no arrancan y ni puta idea de cuál está usando de verdad tu build.

A un animal así no se le domestica. Le construyes un recinto, lo miras a través del cristal y le das de comer entre los barrotes.

Estaba haciendo Dikciz Launcher, una app de pantalla de inicio para Android, y me negué a dejar entrar a la bestia en mi máquina. Así que la toolchain de build y el emulador se fueron a Docker. Luego a la jaula le salieron un comando de screenshot, un dump de la UI, port forwarding, unas cuantas decenas de fixtures para el dispositivo, y llegó un punto en que estaba claro que era un proyecto propio y no un trozo de un launcher. Así que lo arranqué y lo metí en su propio repo: android-lab. Compilas una app Android, arrancas un teléfono, le hurgas en la UI. Tu host recibe Docker, Make, Git y /dev/kvm. No se escapa nada más.

Echando el hormigón: dos imágenes, construidas en tu propio daemon

El recinto son dos imágenes Docker, las dos construidas en local. No se sube nada a ningún registry, nunca, porque estas imágenes llevan artefactos del SDK de Google y no pienso redistribuir esa mierda. Lees la licencia del SDK de Android, la aceptas explícitamente y haces el build:

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

Te quedan dos tags, ambos derivados del archivo VERSION:

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

La imagen de herramientas lleva ADB, Java 17, la plataforma y las build tools de la API 36, Python, pytest, herramientas de shell, Docker y Compose, noVNC y los scripts del lab, todo embebido en /opt/android-lab con un comando android-lab en el PATH. La imagen del emulador lleva el emulador, una imagen de sistema Google APIs x86_64 y su pila de pantalla, encima de un ubuntu:24.04 fijado por digest.

El animal tampoco puede traerse la comida de casa. resolve_android_downloads.py lee en tiempo de build los metadatos del propio repositorio de Google, saca de ahí el SHA-1 de cada artefacto y se niega a seguir si lo que vuelve no es un checksum hex de 40 caracteres. Reconstruyes después de actualizar el checkout y Docker reutiliza cada layer sin cambios, así que no te comes el castigo de la descarga completa cada vez.

Hora de comer: un APK sin una sola cosa de Android en el host

Desde cualquier proyecto Android de tu máquina:

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

El APK cae en la salida de build normal de tu proyecto. Corre con tu UID y tu GID, así que no te quedan cagarrutas de root por el código fuente que luego tengas que limpiar con sudo rm.

El cuidador es un paranoico con lo que pasa entre los barrotes, y cada una de estas reglas es una ruta de error real en gradle-project.sh:

  • ANDROID_PROJECT tiene que ser un directorio relativo dentro del workspace. Nada de traversal con ../../, nada de symlinks que se escapen.
  • El proyecto necesita un Gradle wrapper ejecutable y su archivo de properties.
  • Ese wrapper tiene que fijar distributionSha256Sum. Sin checksum, no hay build. Un Gradle wrapper que descarga lo que le digan es ejecución remota de código con pasos extra.
  • GRADLE_TASK acepta una sola tarea totalmente cualificada. No un string de shell, no una lista de lo que sea.
  • ANDROID_LAB_GRADLE_MAX_WORKERS acepta de 1 a 10.

Las cachés de Gradle y la identidad de firma debug van al directorio .android-lab/ del propio proyecto, no a tu home. La clave de firma es estable, así que un APK recompilado se instala encima del anterior en vez de que Android se ponga a chillar por un signature mismatch. Mete .android-lab/ en tu .gitignore y no lo borres nunca para “actualizar”: ahí dentro también está tu teléfono guardado.

Las raciones están contadas. Los contenedores de herramientas reciben el número de CPUs del host Docker con tope en 10, 8g de memoria con el swap limitado a esos mismos 8g y un límite de 2048 PIDs, y los workers de Gradle siguen el límite de CPU. ¿Quieres recuperar tu máquina mientras compila? make build ANDROID_LAB_TOOL_CPU_LIMIT=2 ANDROID_LAB_GRADLE_MAX_WORKERS=2. Los targets atajo app-build, app-test y app-lint equivalen a :app:assembleDebug, :app:testDebugUnitTest y :app:lintDebug.

Detrás del cristal: un teléfono que miras desde una pestaña del navegador

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

make run arranca un teléfono emulado persistente: 1080×2400, cuatro núcleos virtuales, 4 GiB de RAM en el guest, acelerado con KVM. Luego abres http://127.0.0.1:61326/vnc.html?autoconnect=true&resize=scale y ahí tienes tu teléfono en una pestaña del navegador vía noVNC. Esa es la ventana para los visitantes. make stop tira los contenedores y las redes de este workspace pero no toca el disco del teléfono, así que tus apps instaladas y tus ajustes siguen ahí en el siguiente arranque. make restart reinicia el teléfono sin resetear nada.

El recinto en sí es un archivo Compose con seis servicios: keygen genera las claves ADB, emulator ejecuta el teléfono, adb-bridge, forward y web-vnc se encargan del acceso, y lab ejecuta las operaciones sobre el dispositivo. Cada uno de ellos suelta todas las capabilities, activa no-new-privileges, se niega a hacer pull de imágenes (pull_policy: never) y recibe un límite de PIDs y de memoria. El servicio keygen corre con network_mode: none y la raíz en solo lectura, porque un contenedor cuyo único trabajo es escribir un par de claves no pinta nada en internet. El emulador tiene tope en 4 CPUs y 8g, y su endpoint ADB privado es emulator:5556 en una red interna. Ninguna operación habla jamás con otro dispositivo. Ni el ADB de tu host, ni USB, ni el teléfono de verdad que llevas en el bolsillo.

La libreta del cuidador: el estado de la UI en JSON

Mirar embobado a través del cristal funciona para humanos. Para cualquier cosa automatizada, un PNG no vale para nada y el XML crudo de UIAutomator es una puta ciénaga. Así que make ui-layout vuelca la jerarquía y la pasa por uiautomator_to_json.py, que aplana cada nodo en JSON compacto: className, resourceId, text, contentDesc, una lista interactions (clickable, scrollable, longClickable y compañía), una lista state (checked, enabled, focused, selected), visibleToUser, los bounds y un center x/y precalculado. Eso último es la gracia de todo: lees el JSON, encuentras el botón, tocas en su centro. Sin adivinar píxeles.

Los screenshots, el XML de la UI y el JSON del layout acaban todos en .android-lab/shared/artifacts/.

Enriquecimiento ambiental: una vida entera de pega para el teléfono

Los animales en cautividad necesitan estímulos, o nunca te enseñan cómo se comportan en libertad. El Makefile define 36 targets para el dispositivo, y make help los lista. Están los básicos aburridos: apk-install, app-start, app-stop, app-current, device-shell, file-push, file-pull, home-role-set para que tu app sea el launcher por defecto, accessibility-on y accessibility-off.

Luego están los fixtures de eventos del emulador, que existen porque un launcher tiene que reaccionar al mundo real y yo no iba a pasearme por la calle con un portátil para probar esa mierda. Puedes falsear una posición GPS, poner valores de sensores, recibir un SMS de quien te dé la gana, cambiar la alimentación AC, el Wi-Fi, el Bluetooth, el estado térmico, el ahorro de energía, el device idle, el modo noche, la orientación, la escala de fuente, el modo de timbre, el acceso a la notification policy y el filtro de interrupciones. Encima de eso hay fixtures de calendario para crear, actualizar y borrar eventos, y fixtures de contactos para crear y borrar gente. Todo recibe variables Make con nombre, como EMULATOR_SMS_SENDER y EMULATOR_SMS_BODY, y los valores inválidos fallan antes de tocar el dispositivo.

Entre los barrotes: port forwarding solo en loopback

make forward ANDROID_LAB_FORWARD_HOST_PORT=18080 ANDROID_LAB_FORWARD_DEVICE_PORT=8080
make unforward

Lo que escuche dentro del teléfono en el 8080 aparece en el host en 127.0.0.1:18080. Hay dos túneles: el primero va por defecto al 19001 en ambos extremos, y un segundo, TCP, va por defecto al 19002. Los puertos reenviados y el visor VNC escuchan solo en loopback. No acabas exponiendo nada a tu LAN porque te bailaron los dedos en una dirección de bind.

Un recinto por ejemplar: workspaces y el socket de Docker

Esta es la parte que la gente suele cagar con Docker-in-Docker, así que aquí va quién tiene las llaves de qué:

  • Los builds, el lint y los tests de fixtures no reciben nunca el socket de Docker. Las ejecuciones de Gradle montan el proyecto en /work y es lo único que ven.
  • La gestión del emulador sí recibe el socket, porque tiene que crear contenedores hermanos. El controlador monta tu workspace en su ruta real del host, para que los bind mounts que pasa a los contenedores hermanos se resuelvan en el host, incluso cuando el propio controlador corre dentro de un contenedor.
  • Cada workspace es su propio proyecto Compose, con un nombre sacado de un hash de la ruta del workspace. Dos proyectos Android, dos teléfonos separados, cero colisiones. ANDROID_LAB_COMPOSE_PROJECT sobrescribe ese nombre si quieres compartir el mismo teléfono a propósito.
  • El directorio home de quien llama no se monta nunca. Los datos del teléfono, las claves ADB, los metadatos de forwarding y los artefactos viven en .android-lab/shared/.

Ampliar el hábitat sin tirar paredes

¿Necesitas herramientas extra en la imagen de build? No le hagas fork, deriva de ella:

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

La imagen derivada conserva el comando android-lab. Apunta DEV_IMAGE a ella cuando uses Make, o ANDROID_LAB_DEV_IMAGE para llamadas directas de ciclo de vida, y los contenedores auxiliares también usan tus herramientas.

La orquestación específica de la app va en una extensión de producto. Pones ANDROID_LAB_EXTENSION=scripts/product-lab.sh y el controlador hace source de ese script, pero solo si resuelve dentro del workspace montado. El script define android_lab_extension_command y puede llamar a las funciones genéricas start, forward, compose y run_device_operation. Es código de proyecto de confianza por diseño, no un sandbox, y la documentación lo dice sin rodeos. Resets, clientes de control, nombres de paquetes: todo eso va en el repo de tu app, no en la imagen del lab. Así es exactamente como Dikciz Launcher habla ahora con el lab, desde su propio checkout separado.

Cuidadores amaestrados: skills para el agente que hace la mitad de los clics

El repo trae dos skills de agente en .agents/skills/:

  • android-lab: setup de la imagen, builds de Docker, operaciones del emulador, inspección de la UI y port forwarding.
  • android-lab-kotlin: trabajo con Kotlin, Java y Gradle a través de la toolchain del lab.

Copia los dos directorios enteros en el .agents/skills/ de cualquier proyecto Android y tu agente de código sabe compilar, arrancar el teléfono, leer el JSON del layout y tocar cosas, mientras se le dice a la cara que el ADB del host, USB y los dispositivos físicos están prohibidos, y que parar el stack no le da permiso para borrarte el teléfono guardado. Ejecuta ese agente dentro de aicodebox o claudebox con el socket de Docker del host montado, y todo el ciclo de desarrollo Android ocurre sin que un solo byte del SDK toque tu host.

Inspección de seguridad: zarandear los barrotes a propósito

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

make test cubre la CLI embebida, los límites del proyecto Gradle y la config de Compose renderizada. Las comprobaciones en vivo necesitan KVM: arrancan el lab, lo pinchan y lo vuelven a parar, sin tocar el disco del teléfono. También está make test-vnc para el visor.

Horario de visitas

Solo Linux x86_64 con KVM, así que los del Mac pueden irse a llorarle a su chip M. El código está en github.com/psyb0t/android-lab.

Basta de rollo de zoológico: son dos contenedores y un Makefile, acepta la licencia de Google como un adulto y deja de instalar Android Studio.