A finished signal recording goes to an outbox and is sent gzipped with
the car's token (PUT /v1/logs/{name}); the service retries every 2
minutes while the car is offline. The server keeps the newest 200 on a
new volume (LOGS_DIR); reading takes a token of its own (LOGS_TOKEN).
fetch-logs in the dev shell fetches new ones into logs/.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LhzAVaUyqcw5PkGjz4mWW6
277 lines
13 KiB
Nix
277 lines
13 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"
|
|
'';
|
|
};
|
|
|
|
# Fetches the files the car sent from its Logs tab (signal recordings) from the car
|
|
# server into logs/ (gitignored), those not there yet. Needs the server's LOGS_TOKEN,
|
|
# from $LOGS_TOKEN or ~/.config/nilsmg4/logs-token; sent in a header, never printed.
|
|
fetchLogs = pkgs.writeShellApplication {
|
|
name = "fetch-logs";
|
|
runtimeInputs = [ pkgs.curl pkgs.jq pkgs.coreutils pkgs.git ];
|
|
text = ''
|
|
server=''${SERVER_URL:-https://car.k8s.stinnesbeck.com}
|
|
token=''${LOGS_TOKEN:-}
|
|
if [ -z "$token" ] && [ -f "$HOME/.config/nilsmg4/logs-token" ]; then
|
|
token=$(tr -d '[:space:]' < "$HOME/.config/nilsmg4/logs-token")
|
|
fi
|
|
if [ -z "$token" ]; then
|
|
echo "fetch-logs: no token. Put the server's LOGS_TOKEN in ~/.config/nilsmg4/logs-token (or \$LOGS_TOKEN)." >&2
|
|
exit 1
|
|
fi
|
|
root=$(git rev-parse --show-toplevel)
|
|
mkdir -p "$root/logs"
|
|
auth=(-H "Authorization: Bearer $token")
|
|
fetched=0
|
|
while IFS= read -r name; do
|
|
# The server only stores such names; anything else is not a path to write to.
|
|
if ! [[ "$name" =~ ^[a-z0-9][a-z0-9-]{0,79}\.txt$ ]]; then
|
|
echo "fetch-logs: skipping an unexpected name" >&2
|
|
continue
|
|
fi
|
|
[ -f "$root/logs/$name" ] && continue
|
|
curl -sSf "''${auth[@]}" -o "$root/logs/$name.part" "$server/v1/logs/$name"
|
|
mv "$root/logs/$name.part" "$root/logs/$name"
|
|
echo "logs/$name"
|
|
fetched=$((fetched + 1))
|
|
done < <(curl -sSf "''${auth[@]}" "$server/v1/logs" | jq -r '.logs[].name')
|
|
echo "fetch-logs: $fetched new"
|
|
'';
|
|
};
|
|
in
|
|
{
|
|
default = pkgs.mkShell {
|
|
packages = [ jdk android.androidsdk buildCar publishCar fetchLogs 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"
|
|
echo " fetch-logs # the car's recordings from the server, into logs/"
|
|
'';
|
|
};
|
|
});
|
|
|
|
# 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";
|
|
};
|
|
};
|
|
}
|