The APK's only dex was Slint's. `assemble-apk.sh` found `classes.dex` under the android-activity backend's build directory and copied it in, and there was no `javac` step and no `d8` of anything of ours — package.sh's header said so outright, on the reasoning that the app has no Java because android-activity calls `android_main` directly. That reasoning holds for everything the app *calls* and fails for everything Android *constructs*. A `ContentProvider` is instantiated by the system from its manifest entry; nothing in the process ever reaches its constructor, so there is no JNI route to writing one in Rust. The launch `Intent` is the same shape of problem from the other end: it arrives through `Activity.getIntent()`, and the activity android-activity hands out is a stock `NativeActivity` rather than a subclass with room for code. FR-PLAT-AND-6 needs both, and FR-PLAT-AND-4 needs a foreground `Service`, which is a third. So: everything under `apps/darkroom-android/android/java/` goes through javac against `android.jar`, and d8 merges the classes with Slint's finished dex into one `classes.dex`. Merging rather than emitting a second dex keeps the staging and zip steps as they are — multidex is native at API 28, but two files to keep in step buys nothing at this size. The step is skipped when the tree holds no Java, which is the state this commit leaves it in. Nothing about the APK changes until a `.java` file appears. `-source 8 -target 8 -bootclasspath android.jar` is not caution about language features. It is the last combination in which javac allows the boot class path to be replaced: from `-target 9` the flag is rejected, the platform classes come from the JDK instead of from android.jar, and the build stays green while the device raises `NoClassDefFoundError` for a class Android never shipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
5.3 KiB
Markdown
121 lines
5.3 KiB
Markdown
# 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.
|
|
|
|
## 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:
|
|
|
|
```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.
|