package.sh does two things: it decides how the host reaches the image, and it assembles an APK once inside it. Only the first half is host-specific. CI already runs in that image, so the second half was about to be copied into a workflow step -- two copies of aapt2/zipalign/apksigner ordering, drifting apart at whatever rate the toolchain moves. So it moves to docker/android/assemble-apk.sh, which assumes it is inside the image and takes its paths from the environment, because the callers disagree about them: the container mounts the repo at /work, the runner checks it out wherever it likes. Every default reproduces what package.sh did, so the host path is unchanged. Two things stop being hard-coded on the way. The build-tools version and the compile SDK are resolved from what is installed rather than written out as 36.0.0 and android-36 -- the versions are Dockerfile ARGs, and a second copy is a second thing to miss when they move. --min-sdk-version now comes from that same ARG instead of a literal 28, which is the number the API-level check in CI already reads. The intermediates are removed at the end. They were harmless in a cache directory nobody looks at; beside a published artefact they are four more files for a glob to pick up by mistake. 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.
In CI
.gitea/workflows/android-image.yml builds this image and pushes it to
gitea.tourolle.paris/dtourolle/darkroom-android, which the Android job then runs inside. It is
called on every push and finishes in seconds unless something here changed — the image is tagged
with the git tree hash of docker/android/, so a rebuild happens when and only when a file in this
directory does.
Nothing needs pushing by hand. Editing the Dockerfile is the trigger; latest follows
automatically. To force a rebuild without a content change, run the workflow from the Gitea UI with
force set to true.
The job runs on the host runner rather than in a container, because it needs the Docker daemon and the runner host's cached registry credentials.
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.