Compile our own Java into the APK, so the classes Android constructs can exist

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>
This commit is contained in:
2026-08-29 23:18:18 +02:00
co-authored by Claude Opus 5
parent a2a0131693
commit 24bf5be574
3 changed files with 102 additions and 6 deletions
+21
View File
@@ -51,6 +51,27 @@ inside app-private storage, `run-as` needs a debuggable build, and the app has n
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 |
+76 -1
View File
@@ -103,6 +103,80 @@ DEX="$(find "${TARGET_DIR}/${RUST_TARGET}/release/build" \
[[ -n "${DEX}" ]] || { echo "error: Slint classes.dex not found — did the backend build?" >&2; exit 1; }
echo " dex: ${DEX}"
# ---------------------------------------------------------------------------
# Our own Java.
#
# Almost all of this app is Rust, and the classes here are the exceptions the
# platform forces: Android constructs some things itself, from a class named in
# the manifest, and hands the result back. A `ContentProvider` is one — the
# system instantiates it, nothing in the process ever calls its constructor —
# and reaching the launch `Intent` is another, because it arrives through
# `Activity.getIntent()` and android-activity gives Rust a JNI handle to a
# stock `NativeActivity` rather than a subclass it could have put code in.
# Neither can be written as Rust at any price, so the APK needs a dex of ours.
#
# Skipped when the tree has no Java, which is the state this build was in until
# FR-PLAT-AND-6 and the state a cut-down branch may return to. The step then
# costs nothing and the APK carries Slint's dex alone, exactly as before.
JAVA_SRC="${REPO}/apps/darkroom-android/android/java"
JAVA_FILES=()
if [[ -d "${JAVA_SRC}" ]]; then
mapfile -t JAVA_FILES < <(find "${JAVA_SRC}" -name '*.java' | sort)
fi
if [[ ${#JAVA_FILES[@]} -gt 0 ]]; then
echo "==> compiling ${#JAVA_FILES[@]} Java source(s)"
mkdir -p "${OUT}/classes" "${OUT}/dex"
# `-source 8 -target 8` with an explicit `-bootclasspath`, because that is
# the last combination in which javac still lets the boot class path be
# replaced: from `-target 9` onwards it rejects the flag outright, and the
# platform classes then come from the *JDK* rather than from android.jar.
# That compiles cleanly and fails on the device — a JDK class Android does
# not ship raises NoClassDefFoundError the moment it is touched, with
# nothing at build time having said so. Compiling against android.jar and
# only android.jar is what makes "it compiled" mean "the device has it".
#
# `-Xlint:-options` silences one note, "source value 8 is obsolete", which
# is advice about a future JDK rather than about this code. The JDK is
# pinned in the Dockerfile, so the day it matters is a deliberate bump.
javac \
-source 8 -target 8 \
-bootclasspath "${ANDROID_JAR}" \
-classpath "${ANDROID_JAR}" \
-Xlint:-options \
-d "${OUT}/classes" \
"${JAVA_FILES[@]}"
mapfile -t CLASS_FILES < <(find "${OUT}/classes" -name '*.class' | sort)
# d8 merges, it does not only translate. Handing it Slint's finished
# classes.dex alongside our fresh .class files yields one dex holding both,
# which is what the zip step below already expects. The alternative — ours
# as a second classes2.dex — works at API 28, where multidex is native, but
# leaves two files to keep in step in the staging and zip steps for no gain
# at this size.
#
# `--min-api` is MIN_API for the same reason the linkers use it: d8 decides
# what it must desugar from the oldest device this APK may reach, and a
# higher number here emits bytecode that verifies against the build
# machine's idea of Android and not against that device's.
#
# `--lib` is android.jar rather than a copy of the classpath: desugaring
# needs to see the platform types it is desugaring against, and without it
# d8 reports missing classes for anything our code touches.
"${BT}/d8" \
--release \
--min-api "${MIN_API}" \
--lib "${ANDROID_JAR}" \
--output "${OUT}/dex" \
"${DEX}" \
"${CLASS_FILES[@]}"
DEX="${OUT}/dex/classes.dex"
echo " dex: ${DEX} (ours merged with Slint's)"
fi
# A debug keystore. CI points KEYSTORE at a throwaway directory so nothing is
# persisted or published; package.sh keeps one in the cache on purpose, because
# Android refuses to update an installed app whose signature changed and a new
@@ -234,7 +308,8 @@ echo " signing: ${SIGNING}"
# The intermediates are not the artefact, and leaving them beside it invites
# the wrong file being picked up by a glob.
rm -rf "${OUT}/staging" "${OUT}/res.zip" "${OUT}/base.apk" "${OUT}/unaligned.apk"
rm -rf "${OUT}/staging" "${OUT}/res.zip" "${OUT}/base.apk" "${OUT}/unaligned.apk" \
"${OUT}/classes" "${OUT}/dex"
echo "==> ${OUT}/darkroom.apk"
ls -la "${OUT}/darkroom.apk"
+5 -5
View File
@@ -6,11 +6,11 @@
#
# Gradle would add a second build system, a second dependency tree, and a
# second place for the toolchain versions to drift out of step with the
# Dockerfile. The four tools it would have driven — aapt2, d8, zipalign,
# apksigner — are in build-tools already and are enough on their own, because
# the app has no Java of its own: android-activity's glue calls android_main
# directly, and the only classes in the APK are the ones Slint's build script
# compiles for its own helper.
# Dockerfile. The tools it would have driven — javac, aapt2, d8, zipalign,
# apksigner — are in the image already and are enough on their own, because
# the app is Rust: android-activity's glue calls android_main directly, and
# the only Java in the APK is Slint's helper plus the handful of classes
# Android insists on constructing itself (see assemble-apk.sh's Java step).
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"