Files
nilsandClaude Opus 5.5 253c768781 AGENTS.md: an overview for agents; car-emulator in the flake
AGENTS.md still described upstream's EVABRPUploader (GitHub updates,
the old architecture and tabs). It is now the entry point for agents:
what the project is (app, Go server, k8s, EVHardware), the rules
(nothing touches the cluster, secrets, read-only telemetry, null is not
zero, fail closed, honesty), the facts confirmed on the car, the app's
structure and UI conventions, how to build, test, publish and install,
and which document holds what. SPECS.md stays the journal.

The Android 9 test emulator, so far a file under the ignored build/,
is now reproducible: nix run .#car-emulator creates an AVD at
1920x720/160 dpi with the AOSP image that shares the car's platform key
and starts it on port 5560. Checked from scratch: the car build
installs as the system user.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
2026-10-18 21:12:41 +02:00

240 lines
11 KiB
Nix

{
description = "NilsMG4 — Android Automotive build environment";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs {
inherit system;
config = {
allowUnfree = true;
android_sdk.accept_license = true;
};
}));
# Must match app/build.gradle (compileSdk 36.1) and EVHardware/lib (compileSdk 36).
buildToolsVersion = "36.0.0";
in
{
devShells = forAllSystems (pkgs:
let
android = pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "36" "36.1" ];
buildToolsVersions = [ buildToolsVersion ];
includeEmulator = false;
includeNDK = false;
includeSources = false;
includeSystemImages = false;
};
sdk = "${android.androidsdk}/libexec/android-sdk";
# gradle/gradle-daemon-jvm.properties pins the daemon to JDK 21.
jdk = pkgs.jdk21;
buildTools = "${sdk}/build-tools/${buildToolsVersion}";
# AGP otherwise downloads an aapt2 binary from Maven, which is dynamically linked
# against a generic FHS and does not run on NixOS. Use the SDK's patched one.
# Gradle is also pointed at the Nix JDK so the daemon toolchain criteria are met
# locally instead of downloading a (non-NixOS) JDK through foojay.
gradleOpts = builtins.concatStringsSep " " [
"-Dorg.gradle.project.android.aapt2FromMavenOverride=${buildTools}/aapt2"
"-Dorg.gradle.java.installations.paths=${jdk.home}"
"-Dorg.gradle.java.installations.auto-download=false"
];
# The MG4 head unit is signed with the public AOSP test platform key, so that is
# the key a system-UID (unstable) build must carry. See tools/README.md and OTA.md.
aospKeys = "https://raw.githubusercontent.com/aosp-mirror/platform_build/android-9.0.0_r1/target/product/security";
platformCertSha256 = "c8a2e9bccf597c2fb6dc66bee293fc13f2fc47ec77bc6b2b0d52c11f51192ab8";
buildCar = pkgs.writeShellApplication {
name = "build-car";
runtimeInputs = [ jdk pkgs.curl pkgs.openssl pkgs.coreutils pkgs.gnugrep pkgs.gawk ];
text = ''
root=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
cd "$root"
if [ ! -f app/build.gradle ]; then
echo "build-car: run this from the NilsMG4 checkout" >&2
exit 1
fi
export ANDROID_HOME=${sdk} ANDROID_SDK_ROOT=${sdk} JAVA_HOME=${jdk.home}
export GRADLE_OPTS="${gradleOpts}"
echo "sdk.dir=$ANDROID_HOME" > local.properties
# Keys live in tools/, which .gitignore keeps out of the repository.
for f in platform.pk8 platform.x509.pem; do
if [ ! -f "tools/$f" ]; then
echo "Fetching AOSP test key tools/$f"
curl -sSfL -o "tools/$f" "${aospKeys}/$f"
fi
done
cert=$(openssl x509 -in tools/platform.x509.pem -outform der | sha256sum | cut -d' ' -f1)
if [ "$cert" != "${platformCertSha256}" ]; then
echo "build-car: tools/platform.x509.pem has SHA-256 $cert, expected ${platformCertSha256}" >&2
exit 1
fi
./gradlew assembleUnstableDebug "$@"
version=$(./gradlew -q printUnstableVersion "$@" | tail -1)
in=app/build/outputs/apk/unstable/debug/NilsMG4-unstable-debug.apk
mkdir -p build/car
aligned=build/car/aligned.apk
out="build/car/NilsMG4-unstable-$version.apk"
"${buildTools}/zipalign" -f -p 4 "$in" "$aligned"
# Options come before the input APK; apksigner rejects the other order.
"${buildTools}/apksigner" sign \
--key tools/platform.pk8 --cert tools/platform.x509.pem \
--out "$out" "$aligned"
rm -f "$aligned" "$out.idsig"
signer=$("${buildTools}/apksigner" verify --print-certs "$out" \
| awk -F': ' '/Signer #1 certificate SHA-256 digest/ { print $2 }')
if [ "$signer" != "${platformCertSha256}" ]; then
echo "build-car: $out is signed with $signer, expected ${platformCertSha256}" >&2
rm -f "$out"
exit 1
fi
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
echo
echo "Signed with the AOSP platform key: $out"
echo "Install on the head unit:"
echo " adb push $out /data/local/tmp/update.apk"
echo " adb shell \"pm install -r -f -d -t /data/local/tmp/update.apk\""
'';
};
# Publishes the current car build as a pre-release on the forge, where the car
# server relays it to the in-app updater (server/releases.go, OTA.md). Needs a forge
# token with write access to the repository's releases, from $FORGE_TOKEN or
# ~/.config/nilsmg4/forge-token; it is sent in a header and never printed.
publishCar = pkgs.writeShellApplication {
name = "publish-car";
runtimeInputs = [ pkgs.curl pkgs.jq pkgs.coreutils pkgs.git ];
text = ''
forge=''${FORGE_URL:-https://git.stinnesbeck.com}
repo=''${FORGE_REPO:-nils/mg4}
token=''${FORGE_TOKEN:-}
if [ -z "$token" ] && [ -f "$HOME/.config/nilsmg4/forge-token" ]; then
token=$(tr -d '[:space:]' < "$HOME/.config/nilsmg4/forge-token")
fi
if [ -z "$token" ]; then
echo "publish-car: no token. Put one in ~/.config/nilsmg4/forge-token (or \$FORGE_TOKEN)." >&2
exit 1
fi
root=$(git rev-parse --show-toplevel)
cd "$root"
version=$(grep -m1 'versionName "' app/build.gradle | sed 's/.*versionName "\(.*\)".*/\1/')
apk="build/car/NilsMG4-unstable-$version.apk"
if [ ! -f "$apk" ] || [ ! -f "$apk.sha256" ]; then
echo "publish-car: $apk (and .sha256) not found; run build-car first" >&2
exit 1
fi
if [ "$(sha256sum "$apk" | cut -d' ' -f1)" != "$(cat "$apk.sha256")" ]; then
echo "publish-car: $apk does not match its .sha256" >&2
exit 1
fi
tag="car-$version"
commit=$(git rev-parse HEAD)
api="$forge/api/v1/repos/$repo"
auth=(-H "Authorization: token $token")
if curl -sSf "''${auth[@]}" "$api/releases/tags/$tag" >/dev/null 2>&1; then
echo "publish-car: $tag already exists; bump the version before publishing again" >&2
exit 1
fi
body=$(jq -n --arg tag "$tag" --arg commit "$commit" --arg name "Car build $version" \
'{tag_name: $tag, target_commitish: $commit, name: $name, prerelease: true,
body: "Platform-signed car build, installed by the in-app updater."}')
id=$(curl -sSf "''${auth[@]}" -H "Content-Type: application/json" -d "$body" \
"$api/releases" | jq -r .id)
for f in "$apk" "$apk.sha256"; do
curl -sSf "''${auth[@]}" -F "attachment=@$f" \
"$api/releases/$id/assets?name=$(basename "$f")" >/dev/null
done
echo "Published $tag ($(basename "$apk") and .sha256) at $forge/$repo/releases/tag/$tag"
'';
};
in
{
default = pkgs.mkShell {
packages = [ jdk android.androidsdk buildCar publishCar pkgs.go pkgs.gopls pkgs.govulncheck pkgs.influxdb2-server pkgs.influxdb2-cli ];
ANDROID_HOME = sdk;
ANDROID_SDK_ROOT = sdk;
JAVA_HOME = jdk.home;
GRADLE_OPTS = gradleOpts;
shellHook = ''
echo "sdk.dir=$ANDROID_HOME" > local.properties
echo "NilsMG4 dev shell — JDK $(java -version 2>&1 | head -1 | cut -d'"' -f2), SDK at $ANDROID_HOME"
echo " ./gradlew assembleStableDebug # debug APK"
echo " ./gradlew assembleUnstableDebug # build that can update itself (OTA)"
echo " build-car # unstable build signed for the car"
echo " publish-car # publish it for the in-app updater"
'';
};
});
# An Android 9 emulator shaped like the head unit, for testing the car build: the AOSP
# "default" image is signed with the same public platform key, so the car APK installs
# there as the system user too. 1920x720 at 160 dpi; `adb shell wm size 1778x640` gives
# the app window the car leaves. No vehicle data: every value shows "—". Not in the dev
# shell, because the emulator and its image are a large download.
# nix run .#car-emulator (from the checkout; state in build/emulator/)
packages.x86_64-linux.car-emulator =
let
pkgs = import nixpkgs {
system = "x86_64-linux";
config = { allowUnfree = true; android_sdk.accept_license = true; };
};
emu = (pkgs.androidenv.composeAndroidPackages {
platformVersions = [ "28" ];
includeEmulator = true;
includeSystemImages = true;
systemImageTypes = [ "default" ];
abiVersions = [ "x86_64" ];
includeNDK = false;
}).androidsdk;
in
pkgs.writeShellApplication {
name = "car-emulator";
runtimeInputs = [ pkgs.coreutils pkgs.gnugrep ];
text = ''
root=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
state="$root/build/emulator"
mkdir -p "$state/avd" "$state/home"
export ANDROID_HOME=${emu}/libexec/android-sdk ANDROID_SDK_ROOT=${emu}/libexec/android-sdk
export ANDROID_AVD_HOME="$state/avd" ANDROID_EMULATOR_HOME="$state/home" ANDROID_USER_HOME="$state/home"
avd="$state/avd/mg4test.avd"
if [ ! -d "$avd" ]; then
echo no | ${emu}/bin/avdmanager create avd -n mg4test \
-k "system-images;android-28;default;x86_64" --force -p "$avd"
{
echo "hw.lcd.width=1920"
echo "hw.lcd.height=720"
echo "hw.lcd.density=160"
echo "hw.ramSize=2G"
echo "disk.dataPartition.size=10G"
} >> "$avd/config.ini"
fi
echo "Starting the MG4-shaped emulator on port 5560 (adb: emulator-5560); Ctrl-C stops it."
echo "adb: ${emu}/bin/adb -s emulator-5560"
exec "$ANDROID_HOME/emulator/emulator" -avd mg4test -no-audio -no-boot-anim \
-gpu swiftshader_indirect -no-snapshot -port 5560 "$@"
'';
};
apps.x86_64-linux.car-emulator = {
type = "app";
program = "${self.packages.x86_64-linux.car-emulator}/bin/car-emulator";
};
};
}