diff --git a/docker/android/assemble-apk.sh b/docker/android/assemble-apk.sh new file mode 100755 index 0000000..ae238ea --- /dev/null +++ b/docker/android/assemble-apk.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# Assemble a signed APK from an already-built libdarkroom.so. +# +# This runs *inside* the Android image, where the SDK lives. It is deliberately +# separate from package.sh: package.sh is a host-side convenience that mounts +# the repo into a container and drives the whole build, while CI already runs +# in that image and needs only this half. Keeping the assembly in one file +# means the APK a device gets from `package.sh --install` and the APK CI +# publishes are built by the same code, rather than by two copies that drift. +# +# Everything is overridable, because the two callers disagree about paths: the +# container mounts the repo at /work, CI checks it out wherever the runner +# likes. +# +# REPO repo root (default: this script's ../..) +# TARGET_DIR cargo target directory (default: $REPO/target-android) +# JNILIBS where cargo-ndk wrote the .so (default: $TARGET_DIR/jniLibs) +# OUT output directory (default: $TARGET_DIR/apk) +# KEYSTORE signing keystore (default: $TARGET_DIR/debug.keystore) +# ABI Android ABI (default: arm64-v8a) +# RUST_TARGET Rust target triple (default: aarch64-linux-android) +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +REPO="$(cd "${REPO:-${HERE}/../..}" && pwd)" +TARGET_DIR="${TARGET_DIR:-${REPO}/target-android}" +mkdir -p "${TARGET_DIR}" +TARGET_DIR="$(cd "${TARGET_DIR}" && pwd)" +JNILIBS="${JNILIBS:-${TARGET_DIR}/jniLibs}" +OUT="${OUT:-${TARGET_DIR}/apk}" +KEYSTORE="${KEYSTORE:-${TARGET_DIR}/debug.keystore}" +ABI="${ABI:-arm64-v8a}" +RUST_TARGET="${RUST_TARGET:-aarch64-linux-android}" + +SDK="${ANDROID_HOME:-/opt/android-sdk}" + +# Resolved rather than hard-coded: the versions live in the Dockerfile as ARGs, +# and a second copy here is a second thing to forget when they move. The newest +# installed build-tools wins. +BT="$(find "${SDK}/build-tools" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1)" +[[ -n "${BT}" ]] || { echo "error: no build-tools in ${SDK}" >&2; exit 1; } + +# The Dockerfile sets ANDROID_JAR to the compile SDK; see its comment for why +# that is not the same number as MIN_API. +ANDROID_JAR="${ANDROID_JAR:-$(find "${SDK}/platforms" -maxdepth 1 -name 'android-*' \ + | sort -V | tail -1)/android.jar}" +[[ -f "${ANDROID_JAR}" ]] || { echo "error: no android.jar at ${ANDROID_JAR}" >&2; exit 1; } + +# MIN_API comes from the Dockerfile too, so the manifest the device reads and +# the API the linker targeted cannot disagree. +MIN_API="$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' "${REPO}/docker/android/Dockerfile")" +[[ -n "${MIN_API}" ]] || { echo "error: no ARG MIN_API= in docker/android/Dockerfile" >&2; exit 1; } +TARGET_API="$(basename "$(dirname "${ANDROID_JAR}")" | sed 's/^android-//')" + +# The version, taken from the workspace rather than restated here. See +# package.sh for why versionCode is packed the way it is. +VERSION_NAME="$(sed -n 's/^version = "\(.*\)"$/\1/p' "${REPO}/Cargo.toml" | head -1)" +[[ -n "${VERSION_NAME}" ]] || { echo "error: no version in Cargo.toml" >&2; exit 1; } +VERSION_CODE="$(awk -F. '{ print $1 * 10000 + $2 * 100 + $3 }' <<< "${VERSION_NAME}")" +echo "==> version ${VERSION_NAME} (code ${VERSION_CODE}), min API ${MIN_API}, target API ${TARGET_API}" + +SO="${JNILIBS}/${ABI}/libdarkroom.so" +[[ -f "${SO}" ]] || { echo "error: ${SO} not built" >&2; exit 1; } + +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 "${TARGET_DIR}/${RUST_TARGET}/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-)" +[[ -n "${DEX}" ]] || { echo "error: Slint classes.dex not found — did the backend build?" >&2; exit 1; } +echo " dex: ${DEX}" + +# 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 +# key every build would mean uninstalling before every install. +# +# Debug-signed only. This gets the app onto a test device; it is not a release +# signature, and the store password is the Android convention rather than a +# secret worth protecting. +if [[ ! -f "${KEYSTORE}" ]]; then + echo " generating debug keystore" + mkdir -p "$(dirname "${KEYSTORE}")" + keytool -genkeypair -keystore "${KEYSTORE}" -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 "${REPO}/apps/darkroom-android/android/res" \ + -o "${OUT}/res.zip" + +"${BT}/aapt2" link \ + -I "${ANDROID_JAR}" \ + --manifest "${REPO}/apps/darkroom-android/android/AndroidManifest.xml" \ + -R "${OUT}/res.zip" \ + --min-sdk-version "${MIN_API}" \ + --target-sdk-version "${TARGET_API}" \ + --version-name "${VERSION_NAME}" \ + --version-code "${VERSION_CODE}" \ + -o "${OUT}/base.apk" \ + --auto-add-overlay + +cp "${SO}" "${OUT}/staging/lib/${ABI}/libdarkroom.so" +cp "${DEX}" "${OUT}/staging/classes.dex" + +# -0 "" stores the .so without compression so Android can mmap it directly +# (extractNativeLibs=false territory); for a 37 MB library that also keeps +# install times sane. +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 "${KEYSTORE}" --ks-pass pass:android --key-pass pass:android \ + --min-sdk-version "${MIN_API}" \ + "${OUT}/darkroom.apk" +"${BT}/apksigner" verify --print-certs "${OUT}/darkroom.apk" | head -2 + +# 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" + +echo "==> ${OUT}/darkroom.apk" +ls -la "${OUT}/darkroom.apk" diff --git a/docker/android/package.sh b/docker/android/package.sh index 0839038..d5d14ed 100755 --- a/docker/android/package.sh +++ b/docker/android/package.sh @@ -63,90 +63,15 @@ SO="${CACHE}/target/jniLibs/${ABI}/libdarkroom.so" # --------------------------------------------------------------------------- # 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. +# The assembly itself is assemble-apk.sh, which runs in the image and is shared +# with CI — see its header. Only the mount layout is decided here: the repo is +# at /work and the cache's target directory at /work/target-android, so every +# default in that script already points at the right place. # --------------------------------------------------------------------------- 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}" +"${HERE}/build.sh" env \ + ABI="${ABI}" RUST_TARGET="${RUST_TARGET}" \ + /work/docker/android/assemble-apk.sh # --------------------------------------------------------------------------- # 3. Install from the host.