The decoder can load from a path; nothing yet put a file at one. Four packaging routes, and one lookup that finds the result. ## Not `include_bytes!`, unlike the instance model The instance model is 11 MB and compiled in, which was the right call for it: Android hands the app no filesystem path (ARCH §6.9) and 11 MB is tolerable. The scene model is 24 MB, and 35 MB of constants in the binary is paid by every install whether or not the tab is ever opened. So it follows `models/face/` instead — carried as an APK asset, unpacked once at first launch into the shared directory a desktop install already uses, after which every lookup finds it where it finds a desktop user's. Assets are stored rather than deflated in the APK, so unpacking is a copy rather than an inflate. `embedded-scene-model` exists for the desktop build with nowhere else to read from, and for tests wanting the real graph. Off by default, which is the asymmetry with `embedded-model` and the reason for a separate feature. ## Three files, all or none `scene_model` insists on the graph, its vocabulary and the category descriptor together, for the reason `face_models` insists on its pair: a graph alone decodes to 150 anonymous channels. Reporting the set missing beats starting and failing at the first inference. ## The two model sets are not the same kind of thing `install_bundled_models` now carries both, and the distinction is worth keeping in view. Face weights are absent from the repository *by design* — the InsightFace grant is research-only (docs/faces.md §2) — so a build carrying none is ordinary. The scene model is committed, so a build carrying none means a checkout without `git lfs pull`. Neither is fatal. A photo editor that refuses to start over a missing grading feature is worse than one that starts without it, so both report themselves unavailable exactly as face indexing already did. The LFS-pointer guards apply to the `.onnx` only. The vocabulary and the descriptor are legitimately a few kilobytes, and a size check that fails on them would be a guard against the wrong thing. `scene_model` is exported ahead of the tab that will consume it so the packaging added here has something to be verified against — assets written where no lookup looks would be a silent mistake for as long as the tab took to arrive. Co-Authored-By: Claude Opus 5 (1M context) <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.
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.
Java in the APK
There is no Gradle here and there is no AndroidX, so assemble-apk.sh compiles Java itself:
everything under apps/darkroom-android/android/java/ goes through javac against android.jar,
and d8 merges the result with the dex Slint's build script produced for its own helper. One
classes.dex comes out. Drop a .java file in that tree and the next build picks it up; an empty
tree skips the step entirely and the APK carries Slint's dex alone, which is what it did before the
step existed.
Java is for what Android constructs, and nothing else. The system instantiates a
ContentProvider from its manifest entry, and Activity.getIntent() is only reachable on an
activity object — android-activity hands Rust a JNI handle to a stock NativeActivity, not a
subclass it could have put code in. Those cases need a class in the APK at any price. Everything
else stays in Rust, because a second language is a second place for the logic to live.
The step compiles -source 8 -target 8 with -bootclasspath android.jar. That is not conservatism
about language features — it is the last combination in which javac lets the boot class path be
replaced. From -target 9 the flag is rejected and the platform classes come from the JDK instead,
which compiles and then dies on the device with NoClassDefFoundError for a class Android never
shipped.
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.