diff --git a/docker/android/README.md b/docker/android/README.md index accbc79..6466dd7 100644 --- a/docker/android/README.md +++ b/docker/android/README.md @@ -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 | diff --git a/docker/android/assemble-apk.sh b/docker/android/assemble-apk.sh index 8dca2ef..dfa1f0a 100755 --- a/docker/android/assemble-apk.sh +++ b/docker/android/assemble-apk.sh @@ -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" diff --git a/docker/android/package.sh b/docker/android/package.sh index 61edcda..7c8fa1b 100755 --- a/docker/android/package.sh +++ b/docker/android/package.sh @@ -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)"