O cușcă de zoo pentru Android SDK: android-lab

Android SDK-ul e un animal sălbatic. Lasă-l liber prin casă și îți marchează teritoriul peste tot: un SDK, o grămadă de build tools, un emulator, o imagine de sistem, un JDK care îi place mai mult decât al tău, un director ~/.android plin de chei și configurări AVD, un cache ~/.gradle cât o țară mică și un daemon de fundal care îți tot mănâncă RAM-ul mult după ce ai închis chestia. După șase luni ai trei versiuni de SDK zidite în pereți, două emulatoare care nu mai pornesc și habar n-ai pe care dintre ele îl folosește de fapt build-ul tău.

Un animal ca ăsta nu-l domesticești. Îi construiești un țarc, te uiți la el prin geam și îl hrănești printre gratii.

Lucram la Dikciz Launcher, o aplicație de home screen pentru Android, și am refuzat să las bestia să intre pe mașina mea. Așa că toolchain-ul de build și emulatorul au intrat în Docker. Apoi cuștii i-au crescut o comandă de screenshot, un dump de UI, port forwarding, câteva zeci de fixture-uri pentru device, iar la un moment dat era evident că e o chestie de sine stătătoare, nu o bucată dintr-un launcher. Așa că am smuls-o într-un repo separat: android-lab. Faci build la o aplicație Android, pornești un telefon, îi scotocești prin UI. Host-ul tău primește Docker, Make, Git și /dev/kvm. Nimic altceva nu scapă afară.

Turnăm betonul: două imagini, construite pe propriul tău daemon

Țarcul înseamnă două imagini Docker, ambele construite local. Nu se urcă nimic în niciun registry, niciodată, pentru că imaginile astea conțin artefacte din SDK-ul Google și n-am de gând să redistribui porcăria aia. Citești licența Android SDK, o accepți explicit, faci build:

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

Rămâi cu două tag-uri, ambele derivate din fișierul VERSION:

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

Imaginea de unelte conține ADB, Java 17, platforma și build tools pentru API 36, Python, pytest, unelte de shell, Docker și Compose, noVNC și scripturile lab-ului, toate încorporate în /opt/android-lab, cu o comandă android-lab în PATH. Imaginea de emulator conține emulatorul, o imagine de sistem Google APIs x86_64 și stack-ul ei de afișare, peste un ubuntu:24.04 fixat pe digest.

Nici mâncarea nu și-o aduce animalul de acasă. resolve_android_downloads.py citește la build metadatele din chiar repository-ul Google, scoate de acolo SHA-1-ul fiecărui artefact și refuză să meargă mai departe dacă ce primește nu e un checksum hex de 40 de caractere. Refaci build-ul după ce actualizezi checkout-ul și Docker refolosește fiecare layer neschimbat, deci nu mai înghiți tot downloadul de la zero de fiecare dată.

Ora de masă: un APK fără nicio chestie Android pe host

Din orice proiect Android de pe mașina ta:

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

APK-ul ajunge în output-ul normal de build al proiectului tău. Rulează cu UID-ul și GID-ul tău, deci nu te alegi prin codul sursă cu găinaț deținut de root, pe care să-l cureți mai târziu cu sudo rm.

Îngrijitorul e paranoic cu ce trece printre gratii, și fiecare dintre astea e o ramură de eroare reală în gradle-project.sh:

  • ANDROID_PROJECT trebuie să fie un director relativ din workspace. Fără traversare cu ../../, fără symlink-uri care evadează.
  • Proiectul are nevoie de un Gradle wrapper executabil și de fișierul lui de properties.
  • Wrapper-ul ăla trebuie să fixeze distributionSha256Sum. Fără checksum, fără build. Un Gradle wrapper care descarcă orice i se spune e remote code execution cu pași în plus.
  • GRADLE_TASK primește un singur task complet calificat. Nu un string de shell, nu o listă de te miri ce.
  • ANDROID_LAB_GRADLE_MAX_WORKERS acceptă valori de la 1 la 10.

Cache-urile Gradle și identitatea de semnare debug ajung în directorul .android-lab/ al proiectului, nu în home-ul tău. Cheia de semnare e stabilă, deci un APK refăcut se instalează peste cel vechi, în loc să urle Android-ul despre un signature mismatch. Pune .android-lab/ în .gitignore și nu-l șterge niciodată ca să faci “upgrade”: acolo stă și telefonul tău salvat.

Porțiile sunt raționalizate. Containerele de unelte primesc numărul de CPU-uri al host-ului Docker plafonat la 10, 8g de memorie cu swap-ul plafonat la aceiași 8g și o limită de 2048 de PID-uri, iar workerii Gradle se iau după limita de CPU. Vrei mașina înapoi cât compilează? make build ANDROID_LAB_TOOL_CPU_LIMIT=2 ANDROID_LAB_GRADLE_MAX_WORKERS=2. Target-urile scurtătură app-build, app-test și app-lint corespund lui :app:assembleDebug, :app:testDebugUnitTest și :app:lintDebug.

În spatele geamului: un telefon pe care îl privești dintr-un tab de browser

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

make run pornește un telefon emulat persistent: 1080×2400, patru nuclee virtuale, 4 GiB de RAM în guest, accelerat cu KVM. Apoi deschizi http://127.0.0.1:61326/vnc.html?autoconnect=true&resize=scale și acolo e telefonul tău, într-un tab de browser, prin noVNC. Ăsta e geamul pentru vizitatori. make stop dărâmă containerele și rețelele workspace-ului ăstuia, dar nu se atinge de discul telefonului, deci aplicațiile instalate și setările sunt tot acolo la următoarea pornire. make restart repornește telefonul fără să reseteze nimic.

Țarcul propriu-zis e un fișier Compose cu șase servicii: keygen generează cheile ADB, emulator rulează telefonul, adb-bridge, forward și web-vnc se ocupă de acces, iar lab rulează operațiile pe device. Fiecare dintre ele renunță la toate capabilities, setează no-new-privileges, refuză să tragă imagini (pull_policy: never) și primește o limită de PID-uri și de memorie. Serviciul keygen rulează cu network_mode: none și root read-only, pentru că un container a cărui singură treabă e să scrie o pereche de chei n-are ce căuta pe internet. Emulatorul e plafonat la 4 CPU-uri și 8g, iar endpoint-ul lui ADB privat e emulator:5556, pe o rețea internă. Ăsta e singurul device cu care vorbește vreodată vreo operație. Nu ADB-ul de pe host, nu USB-ul, nu telefonul adevărat din buzunarul tău.

Clipboard-ul îngrijitorului: starea UI-ului ca JSON

Holbatul prin geam merge pentru oameni. Pentru orice e automatizat, un PNG nu-ți folosește la nimic, iar XML-ul brut de UIAutomator e o mlaștină de căcat. Așa că make ui-layout face dump la ierarhie și o trece prin uiautomator_to_json.py, care turtește fiecare nod într-un JSON compact: className, resourceId, text, contentDesc, o listă interactions (clickable, scrollable, longClickable și gașca), o listă state (checked, enabled, focused, selected), visibleToUser, bounds-urile și un center x/y precalculat. Ăla de la urmă e tot rostul: citești JSON-ul, găsești butonul, dai tap în centrul lui. Fără ghicit pixeli.

Screenshot-urile, XML-ul de UI și JSON-ul de layout ajung toate în .android-lab/shared/artifacts/.

Îmbogățirea mediului: o viață întreagă măsluită pentru telefon

Animalele din captivitate au nevoie de stimulare, altfel nu-ți arată niciodată cum se poartă în sălbăticie. Makefile-ul definește 36 de target-uri pentru device, iar make help le listează. Esențialele plictisitoare sunt acolo: apk-install, app-start, app-stop, app-current, device-shell, file-push, file-pull, home-role-set ca să-ți faci aplicația launcher-ul implicit, accessibility-on și accessibility-off.

Apoi vin fixture-urile de evenimente pentru emulator, care există pentru că un launcher trebuie să reacționeze la lumea reală și n-aveam de gând să mă plimb pe afară cu laptopul ca să testez rahatul ăsta. Poți să falsifici o poziție GPS, să setezi valori de senzori, să primești un SMS de la cine vrei tu, să comuți alimentarea AC, Wi-Fi-ul, Bluetooth-ul, starea termică, power save, device idle, night mode, orientarea, scala fontului, ringer mode, accesul la notification policy și filtrul de întreruperi. Peste toate astea ai fixture-uri de calendar ca să creezi, actualizezi și ștergi evenimente, plus fixture-uri de contacte ca să creezi și să ștergi oameni. Totul primește variabile Make cu nume, ca EMULATOR_SMS_SENDER și EMULATOR_SMS_BODY, iar valorile invalide pică înainte să se atingă cineva de device.

Printre gratii: port forwarding doar pe loopback

make forward ANDROID_LAB_FORWARD_HOST_PORT=18080 ANDROID_LAB_FORWARD_DEVICE_PORT=8080
make unforward

Ce ascultă în telefon pe 8080 apare pe host la 127.0.0.1:18080. Sunt două tuneluri: primul are implicit 19001 la ambele capete, iar al doilea, unul TCP, are implicit 19002. Porturile forwardate și viewer-ul VNC ascultă doar pe loopback. Nu ajunge nimic expus în LAN-ul tău doar pentru că ai scris strâmb o adresă de bind.

Câte un țarc pentru fiecare exponat: workspace-uri și socket-ul Docker

Asta e partea pe care lumea o bușește de obicei cu Docker-in-Docker, așa că uite cine primește cheile și la ce:

  • Build-urile, lint-ul și testele de fixture nu primesc niciodată socket-ul Docker. Rulările Gradle montează proiectul la /work și doar atât văd.
  • Managementul emulatorului primește socket-ul, pentru că trebuie să creeze containere surori. Controller-ul îți montează workspace-ul la calea lui reală de pe host, ca bind mount-urile pe care le dă containerelor surori să se rezolve pe host, chiar și când controller-ul însuși rulează într-un container.
  • Fiecare workspace e propriul lui proiect Compose, numit după un hash al căii workspace-ului. Două proiecte Android, două telefoane separate, zero coliziuni. ANDROID_LAB_COMPOSE_PROJECT suprascrie numele dacă vrei intenționat să împarți același telefon.
  • Directorul home al apelantului nu e montat niciodată. Datele telefonului, cheile ADB, metadatele de forwarding și artefactele stau în .android-lab/shared/.

Extindem habitatul fără să dărâmăm pereți

Ai nevoie de unelte în plus în imaginea de build? Nu-i da fork, derivă din ea:

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

Imaginea derivată păstrează comanda android-lab. Pune DEV_IMAGE să arate spre ea când folosești Make, sau ANDROID_LAB_DEV_IMAGE pentru apeluri directe de lifecycle, și containerele helper îți folosesc și ele uneltele.

Orchestrarea specifică aplicației merge într-o extensie de produs. Setezi ANDROID_LAB_EXTENSION=scripts/product-lab.sh și controller-ul face source la scriptul ăla, dar numai dacă se rezolvă în interiorul workspace-ului montat. Scriptul definește android_lab_extension_command și poate apela funcțiile generice start, forward, compose și run_device_operation. E cod de proiect de încredere, intenționat, nu un sandbox, iar documentația o spune pe față. Reset-uri, clienți de control, nume de pachete: toate astea țin de repo-ul aplicației tale, nu de imaginea lab-ului. Exact așa vorbește acum Dikciz Launcher cu lab-ul, din propriul lui checkout separat.

Îngrijitori dresați: skill-uri pentru agentul care face jumătate din click-uri

Repo-ul vine cu două skill-uri de agent în .agents/skills/:

  • android-lab: setup-ul imaginii, build-uri Docker, operații pe emulator, inspecția UI-ului și port forwarding.
  • android-lab-kotlin: treabă de Kotlin, Java și Gradle prin toolchain-ul lab-ului.

Copiezi ambele directoare întregi în .agents/skills/ al oricărui proiect Android și agentul tău de coding știe să facă build, să pornească telefonul, să citească JSON-ul de layout și să dea tap pe chestii, în timp ce i se spune pe șleau că ADB-ul de pe host, USB-ul și device-urile fizice sunt interzise, și că oprirea stack-ului nu înseamnă că are voie să-ți radă telefonul salvat. Rulează agentul ăla în aicodebox sau claudebox cu socket-ul Docker al host-ului montat, și toată bucla de dev Android se petrece fără ca un singur byte de SDK să-ți atingă host-ul.

Inspecția de siguranță: zgâlțâim gratiile intenționat

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

make test acoperă CLI-ul încorporat, granițele proiectului Gradle și config-ul Compose randat. Verificările live au nevoie de KVM: pornesc lab-ul, îl înghiontesc și îl opresc la loc, fără să se atingă de discul telefonului. Mai e și make test-vnc pentru viewer.

Program de vizitare

Doar Linux x86_64 cu KVM, deci cei cu Mac pot să meargă să-și plângă amarul în cipul lor M. Codul e pe github.com/psyb0t/android-lab.

Gata cu grădina zoologică: sunt două containere și un Makefile, acceptă licența Google ca un om mare și termină odată cu instalatul Android Studio.