feat(android-capture): emulateur AVD en service, installation des APK deposes

L'emulateur etait le maillon manquant : la capture tournait deja en service, mais la cible
avd ne vivait que si une session interactive la lancait.

- android-emulator : AVD headless avec -tcpdump vers pcap/avd/. JAVA_HOME force au JDK 17
  qu'androidenv utilise lui-meme, parce que le runner appelle la version non encapsulee du
  SDK, celle qui reclame JAVA_HOME et sans laquelle l'AVD n'est jamais cree.
- android-avd-install : timer qui installe au boot les APK deposes dans apks/.
- aucune unite bloquante : ni wantedBy sur une oneshot d'installation, ni ExecStartPost qui
  attend le boot Android. Une unite laissee en cours de demarrage bloque toute activation
  nixos-rebuild, qui sort en « unit already loaded ».
- le dossier apks/ est cree par le service, une regle tmpfiles ne suffisait pas.

Verifie sur tower : switch code 0, qemu actif sous le service, emu.pcap ecrit et croissant
sous l'utilisateur de capture, co.hinge.app installe dans l'AVD du service.
This commit is contained in:
Hermes Agent
2026-11-03 10:32:23 -05:00
parent 5d65f0ab81
commit ecdc94eec1
+139 -4
View File
@@ -101,6 +101,82 @@ let
echo "${target}: $latest"
'';
# ---- Emulateur AVD en service -------------------------------------------
# Le runner d'androidenv cree l'AVD dans un dossier temporaire, boote, puis sort
# en laissant qemu tourner. Lance tel quel comme ExecStart, systemd verrait le
# service « termine » alors que l'emulateur tourne encore : on l'enveloppe pour
# rester vivant tant que qemu tient la capture, ce qui donne un service que
# systemd suit vraiment et qu'un `systemctl stop` arrete, emulateur compris.
#
# Le chemin du runner est epingle volontairement : il vient d'une flake separee
# (le projet hinge-re, qui construit l'image Play Store). Si cette flake change
# d'image ou d'options, ce chemin change et il faut le mettre a jour ici comme
# dans son justfile.
emulatorRunner = "/nix/store/kyfmkz5jayxkz1gg6s1p9lsgdgjf6mf2-hinge-test/bin/run-test-emulator";
emulatorScript = pkgs.writeShellScript "android-emulator" ''
set -eu
export HOME=${stateDir}
# avdmanager (qui cree l'AVD) est du Java et reclame JAVA_HOME : le runner
# appelle la version NON encapsulee de l'Android SDK, celle qui n'embarque
# pas le JDK. On reprend le JDK qu'androidenv utilise lui-meme dans son
# wrapper (openjdk 17), un JDK plus recent fait echouer ces cmdline-tools.
export JAVA_HOME=${pkgs.jdk17}
export PATH=${pkgs.jdk17}/bin:${pkgs.procps}/bin:${pkgs.coreutils}/bin:$PATH
export NIX_ANDROID_EMULATOR_FLAGS="-no-window -no-audio -gpu swiftshader_indirect -no-snapshot -memory 3072 -no-metrics -tcpdump ${stateDir}/pcap/avd/emu.pcap"
# Le dossier des APK est cree par le service lui-meme : c'est lui qui le
# consomme, et une regle tmpfiles ne garantit pas sa presence au premier
# demarrage qui suit l'ajout de la regle.
install -d -m 0770 ${stateDir}/apks
${emulatorRunner} &
runner=$!
# On attend l'apparition de la capture, borne dans le temps, et on
# echoue si l'emulateur ne demarre pas plutot que de rester en vie pour rien.
demarre=0
for _ in $(seq 1 90); do
if pgrep -f "tcpdump ${stateDir}/pcap/avd/emu.pcap" >/dev/null 2>&1; then
demarre=1
break
fi
# Runner sorti sans lancer qemu : inutile d'attendre la fin du delai.
kill -0 "$runner" 2>/dev/null || break
sleep 2
done
if [ "$demarre" != 1 ]; then
echo "l'emulateur n'a pas demarre" >&2
exit 1
fi
# Reste vivant tant que la capture de cette cible est ouverte : aucun autre
# process de la machine ne porte ce chemin de fichier.
while pgrep -f "tcpdump ${stateDir}/pcap/avd/emu.pcap" >/dev/null 2>&1; do
sleep 10
done
'';
# Installe les APK de travail des que l'appareil a fini de booter. L'AVD etant
# recree a chaque demarrage, l'installation fait partie du cycle de vie du
# service : on depose un fichier dans apks/ (dossier accessible en ecriture au
# groupe) et c'est repris au boot suivant.
installApkScript = pkgs.writeShellScript "android-avd-install" ''
set -u
adb=${pkgs.android-tools}/bin/adb
for _ in $(seq 1 60); do
$adb wait-for-device >/dev/null 2>&1 && break
sleep 5
done
for _ in $(seq 1 60); do
[ "$($adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ] && break
sleep 5
done
for apk in ${stateDir}/apks/*.apk; do
[ -f "$apk" ] || continue
echo "installation de $apk"
$adb install -r "$apk" || echo "echec de l'installation de $apk"
done
'';
hardening = {
User = user;
Group = user;
@@ -130,15 +206,63 @@ in
[
"d ${stateDir} 0750 ${user} ${user} -"
"d ${stateDir}/digest 0770 ${user} ${user} -"
"d ${stateDir}/apks 0770 ${user} ${user} -"
]
++ lib.mapAttrsToList
(target: _: "d ${stateDir}/pcap/${target} 0770 ${user} ${user} -")
captures;
# Une unite de capture par cible qui a une interface hote, plus un digest et un
# timer par cible. Une seule affectation a systemd.services : deux clefs
# identiques dans le meme attrebat seraient une erreur d'evaluation.
# Une seule affectation a systemd.services : en declarer deux dans le meme
# attrebat (services.android-emulator puis services = ...) est une erreur
# d'evaluation, donc tout est fusionne ici.
systemd.services =
{
# L'emulateur lui-meme, en service : sans lui la cible avd ne vit que tant
# qu'une session interactive veut bien le lancer. L'AVD est recree a chaque
# demarrage (le runner d'androidenv travaille dans un dossier temporaire),
# d'ou l'installation des APK deposes dans apks/ juste apres le boot.
android-emulator = {
description = "Emulateur Android (AVD) alimentant la cible de capture avd";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
# PATH n'est pas herite d'une session interactive : sans jdk explicite,
# avdmanager (Java) sort en « JAVA_HOME is not set » et l'AVD n'est jamais
# cree. Sans procps ni coreutils, les `seq` et `pgrep` du script sont
# introuvables et l'unite se croit terminee aussitot.
path = with pkgs; [ jdk procps coreutils android-tools ];
serviceConfig = hardening // {
# Type simple : l'unite est consideree demarree des le fork, donc un
# switch n'attend jamais le boot de l'AVD. Le script reste vivant pour
# que systemd suive l'emulateur, et echoue borne dans le temps si
# l'emulateur ne demarre pas.
ExecStart = "${emulatorScript}";
Restart = "on-failure";
RestartSec = "20s";
# qemu demande du temps pour rendre la main proprement.
TimeoutStopSec = "60s";
# /dev/kvm est en 0666 sur cette machine : aucun groupe kvm a rejoindre.
};
};
# L'installation des APK de travail attend le boot d'un AVD neuf, donc
# plusieurs minutes : unite separee pour que l'emulateur demarre tout de
# suite et que le systeme n'attende pas un boot Android.
android-avd-install = {
description = "Installation des APK de travail dans l'AVD";
# Pas de wantedBy : une oneshot tiree par multi-user.target retient toute
# l'activation, donc le demarrage du systeme, pendant le boot Android.
# C'est un timer qui la declenche, et un timer ne retient rien.
after = [ "android-emulator.service" ];
path = with pkgs; [ android-tools coreutils ];
serviceConfig = hardening // {
Type = "oneshot";
ExecStart = "${installApkScript}";
# Un AVD neuf peut mettre dix minutes a booter sur un hote charge.
TimeoutStartSec = "900";
};
};
}
//
lib.mapAttrs'
(target: ifaces:
lib.nameValuePair "android-capture-${target}" {
@@ -169,7 +293,18 @@ in
})
captures;
systemd.timers = lib.mapAttrs'
systemd.timers =
{
android-avd-install = {
description = "Installation des APK de travail dans l'AVD apres le boot";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "2min";
Unit = "android-avd-install.service";
};
};
}
// lib.mapAttrs'
(target: _:
lib.nameValuePair "android-capture-digest-${target}" {
description = "Digest periodique de la capture Android (${target})";