#!/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"