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>
142 lines
6.3 KiB
Bash
Executable File
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
|