The Android job's "Verify minimum API level" step has never verified the minimum API level. It took the first `*.so` anywhere under the target directory, which is a host proc-macro from debug/deps — an x86-64 object built by the runner's gcc, whose .comment section cannot mention Android and so can never contradict the expected value. It now reads the artifact under the target triple, compares against MIN_API parsed from the Dockerfile rather than a second copy of the number, and fails on a mismatch. Both sides are checked non-empty first: two failed parses would otherwise compare equal and pass, which is the same silent success in a new costume. The Android image installs one SDK package per layer and keeps the output. sdkmanager is a JVM program that aborts when it cannot get memory, and the single `> /dev/null` step reported that as a bare "exit code 134" while a retry re-downloaded everything that had already succeeded. tools/ci-local.sh runs all four jobs — desktop, android, layering, traceability — against the host toolchain, which is pinned to the same 1.92.0 CI installs. Its matrix check compares regeneration against the working tree rather than against HEAD: CI starts from a clean checkout, so git's answer is the right one there and reports every local run stale here. The rest is rustfmt across the workspace, and the clippy findings that surfaced once it did: manual_contains in dr-thumbs and collections_ui, a map iterated as pairs for its keys, an index loop over a slice, and two runtime assertions on a constant now made at compile time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android build environment
Reproducible container for cross-compiling DarkRoom to Android. CI and local builds use the same image, so a break appears in one place rather than two.
Use
# Cross-compile for a device ABI
./docker/android/build.sh cargo ndk -t arm64-v8a build --release
# All four ABIs
./docker/android/build.sh cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -t x86 build --release
# Type-check without linking
./docker/android/build.sh cargo check --target aarch64-linux-android
# Interactive shell
./docker/android/build.sh
# After editing the Dockerfile
./docker/android/build.sh --rebuild
Prefers podman, falls back to docker. First run builds the image, which takes several minutes;
after that it is cached.
Pinned versions
| Component | Version | Why this one |
|---|---|---|
| JDK | 17 | AGP's stable target. The host's JDK 25 breaks Gradle. |
| Compile SDK | API 36 | Required for Play distribution |
| Min API | 28 (Android 9) | Floor where Vulkan support is dependable (NFR-COMPAT-1) |
| Build tools | 36.0.0 | Matches compile SDK |
| NDK | 27.2.12479018 (r27c) | Current LTS |
| Rust | 1.92.0 | Slint 1.17 requires it; cargo-ndk 4.1.x needs ≥ 1.86 |
| cargo-ndk | 4.1.2 |
Bumping any of these is a reviewable change, not silent drift.
Compile SDK versus min API
These are different and both matter. ANDROID_API (36) is what the SDK compiles against — the
newest APIs available. MIN_API (28) is what native code links against, and determines the
oldest device that can run the result.
cargo-ndk defaults to API 21 if not told otherwise, which is far below what this app needs.
CARGO_NDK_PLATFORM and the per-target linker paths both pin it to MIN_API. Verify with:
file target/aarch64-linux-android/release/*.so
# → "... for Android 28, built by NDK r27c"
Caching
The cargo registry and target directory persist in ~/.cache/darkroom-android, so rebuilds do not
re-download the crate index. Clear with:
rm -rf ~/.cache/darkroom-android
The Android target dir is kept separate from the host's target/ — sharing one directory between
host and container builds causes constant rebuilds as they invalidate each other's fingerprints.
What is not here
No emulator, no adb device access. This image cross-compiles; it does not run or deploy. Device
testing (spikes S2 and S10, which need two GPU vendors) happens on real hardware — the emulator's
GPU behaviour is not representative of Adreno or Mali, which is precisely what those spikes exist
to test.