# 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 ```bash # 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. ## Face models `package.sh` bundles `models/face/` into the APK, and the entry point unpacks it on first launch. That directory is shared with the Arch package rather than owned by this platform. **The models are in LFS**, so a clone without `git lfs pull` has 130-byte pointers there; the build detects that and stops rather than shipping them. This exists because Android offers no other route to a model: the directory the app reads from is inside app-private storage, `run-as` needs a debuggable build, and the app has no picker and no fetch. docs/faces.md §2.2a is the decision and its limits — these files come back out before anything is published. ## 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: ```bash 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: ```bash 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.