Files
DarkRoom/docker/android/package.sh
T
dtourolleandClaude Opus 5 5a0a9719eb Set the version once, in one place, for every artefact
The workspace read 0.1.0 for two releases, so every desktop binary reported a
version two releases stale. The APK was worse: `AndroidManifest.xml` states no
version at all, so a device showed `versionName=null` and `versionCode=0` while
the library inside the APK knew exactly what it was. A version edited by hand
in several files is a version that is wrong in at least one of them.

`tools/set-version.sh` is now the only thing that sets one. It takes the
version from the latest git tag, or is told, and writes the two files that must
state it before anything is built: the workspace `Cargo.toml`, from which every
crate inherits, and `packaging/PKGBUILD`, which pacman reads before a build
exists. It refreshes `Cargo.lock`, because members appear there by version and
CI builds `--locked`. `--commit` commits the result.

Android is not in that list on purpose. `package.sh` reads the version out of
`Cargo.toml` and hands it to `aapt2 link`, so the APK cannot drift from the
binary it contains — there is no third file to forget. `versionCode` has to be
one increasing integer, which a semantic version is not, so it is packed as
MAJOR*10000 + MINOR*100 + PATCH: ordered the way Android requires, and readable
at a glance.

A version that is not MAJOR.MINOR.PATCH is refused rather than coerced. It is
a contract with whoever reads a bug report, and silently turning "0.4" into
something else is worse than being asked to type it again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 11:09:03 +02:00

164 lines
7.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Build DarkRoom into an installable APK, without Gradle.
#
# ./docker/android/package.sh # build + package, debug-signed
# ./docker/android/package.sh --install # ...and adb install to a device
#
# 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.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${HERE}/../.." && pwd)"
ABI="arm64-v8a"
RUST_TARGET="aarch64-linux-android"
PKG_DIR="${REPO}/apps/darkroom-android/android"
# The container writes here (see build.sh); the APK is assembled in the same
# place so both halves of the build agree on one output directory.
CACHE="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android"
# Under the cache's target/ rather than beside it: the container writes to
# /work/target-android/apk, and that is the directory bind-mounted here.
OUT="${CACHE}/target/apk"
APK="${OUT}/darkroom.apk"
# The version, taken from the workspace rather than restated here.
#
# `AndroidManifest.xml` deliberately declares none: a manifest that states a
# version is a second place for one to be wrong, and it was — the APK reported
# `versionName=null` and `versionCode=0` on the device while the binary inside
# it knew perfectly well what it was.
#
# `versionCode` must be a single increasing integer, which a semantic version
# is not, so it is packed: MAJOR*10000 + MINOR*100 + PATCH. That keeps the
# ordering Android needs (it refuses to install an APK whose code is lower than
# the installed one) and stays readable — 0.4.0 is 400. It allows 99 minors and
# 99 patches per major, which is a limit worth knowing about and a long way off.
VERSION_NAME="$(sed -n 's/^version = "\(.*\)"$/\1/p' "${REPO}/Cargo.toml" | head -1)"
if [[ -z "${VERSION_NAME}" ]]; then
echo "error: no version in Cargo.toml" >&2
exit 1
fi
VERSION_CODE="$(awk -F. '{ print $1 * 10000 + $2 * 100 + $3 }' <<< "${VERSION_NAME}")"
echo "==> version ${VERSION_NAME} (code ${VERSION_CODE})"
INSTALL=0
[[ "${1:-}" == "--install" ]] && INSTALL=1
# ---------------------------------------------------------------------------
# 1. Cross-compile the shared library.
# ---------------------------------------------------------------------------
echo "==> building libdarkroom.so (${ABI})"
"${HERE}/build.sh" cargo ndk -t "${ABI}" -o /work/target-android/jniLibs \
build --release -p darkroom-android
SO="${CACHE}/target/jniLibs/${ABI}/libdarkroom.so"
[[ -f "${SO}" ]] || { echo "error: ${SO} not built" >&2; exit 1; }
# ---------------------------------------------------------------------------
# 2. Assemble the APK inside the container, where the SDK lives.
#
# Everything below runs in one container invocation: aapt2 link produces a base
# APK with the manifest, then the .so and Slint's dex are added as stored
# entries, then zipalign and apksigner finish it. The .so is stored rather than
# deflated so Android can mmap it directly (extractNativeLibs=false territory);
# for a 37 MB library that also keeps install times sane.
# ---------------------------------------------------------------------------
echo "==> packaging APK"
"${HERE}/build.sh" bash -euo pipefail -c '
SDK=/opt/android-sdk
BT="${SDK}/build-tools/36.0.0"
ABI="'"${ABI}"'"
RT="'"${RUST_TARGET}"'"
OUT=/work/target-android/apk
rm -rf "${OUT}" && mkdir -p "${OUT}/staging/lib/${ABI}"
# Slint compiles a Java helper (SlintAndroidJavaHelper) in its build script
# and dexes it. The build-dir hash changes whenever its inputs change, so
# find it rather than hard-coding a path; the newest wins if stale
# directories from earlier builds are still around.
DEX="$(find "/work/target-android/${RT}/release/build" \
-path "*i-slint-backend-android-activity*/out/classes.dex" \
-printf "%T@ %p\n" 2>/dev/null | sort -rn | head -1 | cut -d" " -f2-)"
if [[ -z "${DEX}" ]]; then
echo "error: Slint classes.dex not found — did the backend build?" >&2
exit 1
fi
echo " dex: ${DEX}"
# A debug keystore, created once and kept in the cache. Debug-signed only:
# this exists to get the app onto a test device, not to release it.
KS=/work/target-android/debug.keystore
if [[ ! -f "${KS}" ]]; then
echo " generating debug keystore"
keytool -genkeypair -keystore "${KS}" -alias androiddebugkey \
-storepass android -keypass android \
-keyalg RSA -keysize 2048 -validity 10950 \
-dname "CN=Android Debug,O=Android,C=US" >/dev/null 2>&1
fi
# The launcher icon is the only resource the app has, but resources go
# through aapt2 in two steps regardless: compile turns the source tree into
# an intermediate archive of flat files, link folds that into the APK and
# builds the resources.arsc table that @mipmap/ic_launcher in the manifest
# resolves against. Skipping compile and handing link the directory does
# not work — link only reads compiled input.
"${BT}/aapt2" compile \
--dir /work/apps/darkroom-android/android/res \
-o "${OUT}/res.zip"
# aapt2 link needs the compile SDK to resolve android: attributes, and
# --min-sdk-version is what ends up in the manifest the device reads.
"${BT}/aapt2" link \
-I "${SDK}/platforms/android-36/android.jar" \
--manifest /work/apps/darkroom-android/android/AndroidManifest.xml \
-R "${OUT}/res.zip" \
--min-sdk-version 28 \
--target-sdk-version 36 \
--version-name "'"${VERSION_NAME}"'" \
--version-code "'"${VERSION_CODE}"'" \
-o "${OUT}/base.apk" \
--auto-add-overlay
cp "/work/target-android/jniLibs/${ABI}/libdarkroom.so" \
"${OUT}/staging/lib/${ABI}/libdarkroom.so"
cp "${DEX}" "${OUT}/staging/classes.dex"
# -0 "" stores without compression; see the note above about mmap.
cd "${OUT}/staging"
cp "${OUT}/base.apk" "${OUT}/unaligned.apk"
zip -q -0 -X "${OUT}/unaligned.apk" "lib/${ABI}/libdarkroom.so"
zip -q -X "${OUT}/unaligned.apk" classes.dex
# zipalign before signing: apksigner preserves alignment, the reverse order
# invalidates the signature.
"${BT}/zipalign" -p -f 4 "${OUT}/unaligned.apk" "${OUT}/darkroom.apk"
"${BT}/apksigner" sign \
--ks "${KS}" --ks-pass pass:android --key-pass pass:android \
--min-sdk-version 28 \
"${OUT}/darkroom.apk"
"${BT}/apksigner" verify --print-certs "${OUT}/darkroom.apk" | head -2
'
echo "==> ${APK}"
ls -la "${APK}"
# ---------------------------------------------------------------------------
# 3. Install from the host.
#
# The container has adb but no device: build.sh mounts no USB and shares no
# network, so the host's already-authorised adb server is the shorter path.
# ---------------------------------------------------------------------------
if [[ "${INSTALL}" == "1" ]]; then
command -v adb >/dev/null || { echo "error: adb not on PATH" >&2; exit 1; }
echo "==> installing"
adb install -r "${APK}"
echo "==> launching"
adb shell am start -n paris.tourolle.darkroom/android.app.NativeActivity
fi