Merge: compile our own Java into the APK, and answer an image intent

The APK carried no Java of its own -- the only dex in it was Slint's --
which blocked FR-PLAT-AND-4's foreground service and FR-PLAT-AND-6's
outbound half alike. assemble-apk.sh now compiles everything under
android/java against android.jar and has d8 merge it with Slint's dex
into one classes.dex. With no .java in the tree the step is skipped and
the APK is byte-for-byte what it was.

-source 8 -target 8 -bootclasspath android.jar is load-bearing: from
-target 9 javac rejects -bootclasspath, platform classes then come from
the JDK instead of android.jar, and the build stays green while the
device raises NoClassDefFoundError.

FR-PLAT-AND-6 with it: VIEW, SEND and SEND_MULTIPLE filters, the launch
Intent read over JNI before Slint is given the app, and a hand-written
ExportProvider rooted at getFilesDir() rather than AndroidX FileProvider,
which would have meant Gradle. singleTask, because the new filters let
another app launch the activity while it runs and the default mode would
start a second android_main, Slint backend and wgpu device in one
process. Classes load through the activity's loader; FindClass on
android_main's thread sees only the system loader.

The share half has no caller yet and is deliberately untagged. The five
tests read the manifest and the Java through include_str!, so they fail
if a filter goes, if the activity stops being singleTask, if the provider
becomes exported, or if the authority and the class drift apart.

Verified: javac, d8 and a real dex merge run against the host SDK;
aapt2 link over the manifest; clippy -D warnings clean; 5 tests pass.
Not verified: anything needing a device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 00:00:39 +02:00
co-authored by Claude Opus 5
10 changed files with 1177 additions and 9 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)"