Files
DarkRoom/docker/android/package.sh
T
dtourolleandClaude Opus 5 3cfa78cde2 Give the app a face and a name on the launcher
There was no icon anywhere, and on Android that was not a missing line in the
manifest. `aapt2 link` was being handed a manifest and nothing else, so the
APK carried no res/ and no resources.arsc — there was no table for an
`@mipmap/...` reference to resolve against even if one had been written.
Packaging now compiles the resource tree first and links the result in, which
is the two steps aapt2 insists on: link reads compiled input only, never a
directory.

That absent table is also why the launcher caption was blank, which had
looked like a second, separate bug. `android:label="DarkRoom"` was there and
correct the whole time, and Settings' App info read it fine; the launcher
could not, because resolving a label goes through the package's Resources and
there were none to open. Nothing about the label changed here. It came back
with the table under it.

`android:icon` then names one drawable for both icon generations, because the
`anydpi-v26` qualifier is what separates them. API 26 and up take the adaptive
icon and its three layers; the third of those, monochrome, is what lets
Android 13 recolour it rather than drop the app out of the themed set. Below
26 the same name lands on a density-matched PNG. `roundIcon` is deliberately
absent — a launcher old enough to read it is one that would ignore the
adaptive XML, and minSdk is 28.

The desktop icon is one `@image-url` on the window, and the only raster asset
in a UI that is otherwise entirely Path. The reasoning at the top of
icons.slint does not reach it: that is about glyphs a font might not carry,
and this image is never drawn by us at all. It goes to the window manager,
which wants pixels and composites them unmasked, so it is pre-shaped with
rounded corners rather than square the way the Android layers are.

Which exposed Slint's resource default. An `@image-url` compiles down to the
absolute path it had on the build machine, to be opened at runtime — already
wrong for Android, where the build happens under /work inside a container and
no such directory exists on the device, and wrong silently, as an image that
loads empty. `EmbedFiles` puts the bytes in the binary instead. It reaches
nothing else, since every glyph is a Path.

Verified on a device: the APK installs and the home screen draws both the
icon and "DarkRoom" under it, where before it had neither. In the link step
the adaptive icon resolves at all six densities and resources.arsc lands
uncompressed, which API 30 requires and the existing zipalign preserves. On
the desktop by reading _NET_WM_ICON off the running window — 256x256, as
handed over. Where that actually shows is narrower than it sounds, and the
comment says so: Wayland ignores the property in favour of matching app_id
against an installed .desktop file, which this repo does not install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 20:25:22 +02:00

142 lines
6.3 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"
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 \
-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