#!/usr/bin/env bash # Build a self-signed Android APK from solitaire_app's cdylib targets. # # Replaces the cargo-apk pipeline with explicit cargo-ndk + aapt2 + apksigner # steps. The CI runner was hitting an SDK-discovery bug inside cargo-apk's # ndk-build crate that we couldn't isolate; running each Android toolchain # step explicitly gives us a debuggable pipeline. # # Environment: # ANDROID_HOME Path to Android SDK root. If unset, common SDK # locations such as ~/Android/Sdk are tried. # ANDROID_NDK_HOME Path to the specific NDK version. If unset, the # newest $ANDROID_HOME/ndk/* directory is used. # BUILD_TOOLS_VERSION e.g. "34.0.0". If unset, newest installed build-tools # version is used. # PLATFORM e.g. "android-34". If unset, newest installed # $ANDROID_HOME/platforms/android-* platform is used. # # Optional environment: # PROFILE "debug" (default) | "release" # ABIS Space-separated Android ABIs to build (default: # "arm64-v8a armeabi-v7a x86_64"). Reduce in CI to # fit the runner's disk budget — a full three-ABI # debug build can exceed 25 GB of target/ output. # APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk) # STRIP_NATIVE_LIBS 1 to strip .so files before packaging (default: 1) # KEYSTORE Path to keystore for signing (default: target/android/debug.keystore) # KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore) # KEY_ALIAS Key alias (default: "androiddebugkey") # KEY_PASS Key password (default: same as KEYSTORE_PASS) # # Outputs: # $APK_OUT Signed, zipaligned APK set -euo pipefail infer_latest_dir_name() { local pattern="$1" local latest="" shopt -s nullglob local dirs=( $pattern ) shopt -u nullglob if [ ${#dirs[@]} -gt 0 ]; then latest="$(printf '%s\n' "${dirs[@]}" | sort -V | tail -n 1)" basename "$latest" fi } if [ -z "${ANDROID_HOME:-}" ]; then for candidate in "$HOME/Android/Sdk" "$HOME/Library/Android/sdk" "/opt/android-sdk" "/usr/lib/android-sdk"; do if [ -d "$candidate" ]; then ANDROID_HOME="$candidate" export ANDROID_HOME break fi done fi : "${ANDROID_HOME:?ANDROID_HOME must be set or discoverable under a common SDK path}" if [ -z "${ANDROID_NDK_HOME:-}" ]; then NDK_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/ndk/*")" if [ -n "$NDK_VERSION" ]; then ANDROID_NDK_HOME="$ANDROID_HOME/ndk/$NDK_VERSION" export ANDROID_NDK_HOME fi fi : "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set or discoverable under ANDROID_HOME/ndk}" if [ -z "${BUILD_TOOLS_VERSION:-}" ]; then BUILD_TOOLS_VERSION="$(infer_latest_dir_name "$ANDROID_HOME/build-tools/*")" export BUILD_TOOLS_VERSION fi : "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set or discoverable under ANDROID_HOME/build-tools}" if [ -z "${PLATFORM:-}" ]; then PLATFORM="$(infer_latest_dir_name "$ANDROID_HOME/platforms/android-*")" export PLATFORM fi : "${PLATFORM:?PLATFORM must be set or discoverable under ANDROID_HOME/platforms}" PROFILE="${PROFILE:-debug}" ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}" APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}" STRIP_NATIVE_LIBS="${STRIP_NATIVE_LIBS:-1}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" echo ">>> Android SDK: $ANDROID_HOME" echo ">>> Android NDK: $ANDROID_NDK_HOME" echo ">>> Build tools: $BUILD_TOOLS_VERSION" echo ">>> Platform: $PLATFORM" BT="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION" PLATFORM_JAR="$ANDROID_HOME/platforms/$PLATFORM/android.jar" MANIFEST="solitaire_app/android/AndroidManifest.xml" RES_DIR="solitaire_app/res" ASSETS_DIR="assets" # --- sanity ---------------------------------------------------------------- for f in "$BT/aapt2" "$BT/zipalign" "$BT/apksigner" "$PLATFORM_JAR" "$MANIFEST"; do [ -e "$f" ] || { echo "missing: $f"; exit 1; } done STAGING="$(mktemp -d)" trap 'rm -rf "$STAGING"' EXIT mkdir -p "$STAGING/lib" "$STAGING/compiled-res" # --- 1. native libraries via cargo-ndk ------------------------------------- # `-o $STAGING/lib` lays out files as $STAGING/lib//libsolitaire_app.so # which is the directory structure the APK expects under lib/. CARGO_NDK_ARGS=( --platform 26 -o "$STAGING/lib" ) for abi in $ABIS; do CARGO_NDK_ARGS+=( -t "$abi" ) done CARGO_NDK_ARGS+=( build --package solitaire_app --lib ) if [ "$PROFILE" = "release" ]; then CARGO_NDK_ARGS+=( --release ) fi echo ">>> cargo ndk ${CARGO_NDK_ARGS[*]}" cargo ndk "${CARGO_NDK_ARGS[@]}" if [ "$STRIP_NATIVE_LIBS" = "1" ]; then LLVM_STRIP="" shopt -s nullglob STRIP_CANDIDATES=( "$ANDROID_NDK_HOME"/toolchains/llvm/prebuilt/*/bin/llvm-strip ) shopt -u nullglob if [ ${#STRIP_CANDIDATES[@]} -gt 0 ]; then LLVM_STRIP="${STRIP_CANDIDATES[0]}" fi if [ -z "$LLVM_STRIP" ]; then echo "llvm-strip not found under ANDROID_NDK_HOME; native libraries will remain unstripped" >&2 else echo ">>> strip native libraries with $LLVM_STRIP" find "$STAGING/lib" -name '*.so' -print0 | while IFS= read -r -d '' so; do "$LLVM_STRIP" --strip-debug "$so" done fi fi # --- 2. compile + link resources and manifest ------------------------------ if [ -d "$RES_DIR" ]; then echo ">>> aapt2 compile resources" "$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res" fi # Derive versionCode/versionName from VERSION_NAME env var (e.g. "v0.28.0" → code 2800, name "0.28.0"). # AndroidManifest.xml intentionally has no versionCode/versionName — aapt2's --version-* flags only # inject when absent, so the manifest must be clean for CI injection to work. Local debug builds # fall back to code=1 / name="0.0.0-dev". if [ -n "${VERSION_NAME:-}" ]; then VN="${VERSION_NAME#v}" IFS='.' read -r _MAJ _MIN _PAT <<< "$VN" VERSION_CODE=$(( ${_MAJ:-0} * 10000 + ${_MIN:-0} * 100 + ${_PAT:-0} )) else VERSION_CODE=1 VERSION_NAME="0.0.0-dev" fi LINK_ARGS=( link -o "$STAGING/app-unsigned.apk" -I "$PLATFORM_JAR" --manifest "$MANIFEST" ) [ -n "$VERSION_CODE" ] && LINK_ARGS+=( --version-code "$VERSION_CODE" ) [ -n "${VERSION_NAME:-}" ] && LINK_ARGS+=( --version-name "${VERSION_NAME#v}" ) [ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" ) # Add compiled resources if any shopt -s nullglob RES_FLATS=( "$STAGING/compiled-res"/*.flat ) shopt -u nullglob if [ ${#RES_FLATS[@]} -gt 0 ]; then LINK_ARGS+=( "${RES_FLATS[@]}" ) fi echo ">>> aapt2 link" "$BT/aapt2" "${LINK_ARGS[@]}" # --- 3. add native libraries to the APK ------------------------------------ echo ">>> bundle native libraries" ( cd "$STAGING" && zip -r -q app-unsigned.apk lib/ ) # --- 4. zipalign ----------------------------------------------------------- echo ">>> zipalign" "$BT/zipalign" -p -f 4 "$STAGING/app-unsigned.apk" "$STAGING/app-aligned.apk" # Free the unsigned intermediate now — apksigner reads $app-aligned.apk and # writes $APK_OUT, and the runner's disk is tight after a multi-ABI build. rm -f "$STAGING/app-unsigned.apk" # --- 5. sign --------------------------------------------------------------- if [ -z "${KEYSTORE:-}" ]; then KEYSTORE="target/android/debug.keystore" fi KEYSTORE_PASS="${KEYSTORE_PASS:-android}" KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}" KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}" if [ ! -f "$KEYSTORE" ]; then mkdir -p "$(dirname "$KEYSTORE")" echo ">>> generating debug keystore at $KEYSTORE" keytool -genkeypair -v \ -keystore "$KEYSTORE" \ -storepass "$KEYSTORE_PASS" \ -alias "$KEY_ALIAS" \ -keypass "$KEY_PASS" \ -keyalg RSA -keysize 2048 -validity 10000 \ -dname "CN=Android Debug,O=Android,C=US" > /dev/null fi KEYSTORE_PASS="${KEYSTORE_PASS:-android}" KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}" KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}" mkdir -p "$(dirname "$APK_OUT")" echo ">>> apksigner sign -> $APK_OUT" "$BT/apksigner" sign \ --ks "$KEYSTORE" \ --ks-pass "pass:$KEYSTORE_PASS" \ --ks-key-alias "$KEY_ALIAS" \ --key-pass "pass:$KEY_PASS" \ --out "$APK_OUT" \ "$STAGING/app-aligned.apk" echo ">>> verify" "$BT/apksigner" verify --verbose "$APK_OUT" echo ">>> done: $APK_OUT"