3627e9f9cf
The apksigner step relied on auto scheme selection, which produced an APK carrying invalid v1 (JAR) signature files: META-INF/*.SF and *.RSA were present but failed v1 verification (apksigner reports `v1 scheme: false` while v2/v3 verify). Android installs such an APK fine via v2/v3, but Obtainium parses the legacy v1 certificate at install time, gets an empty cert list, and crashes with: RangeError (length): Invalid value: valid value range is empty: 0 This is why the app adds fine in Obtainium (Gitea API only) but fails on install (APK parse). minSdk is 26, so v1/JAR signing is unnecessary — sign explicit v2+v3 only (matching modern Android tooling for minSdk >= 24) and pass --min-sdk-version 26. Adds a post-sign guard that fails the build if any META-INF v1 signature files remain. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
248 lines
9.4 KiB
Bash
Executable File
248 lines
9.4 KiB
Bash
Executable File
#!/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/<abi>/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"
|
|
# Sign the schemes explicitly instead of relying on apksigner's auto behaviour.
|
|
# Left to "auto", this pipeline produced an APK carrying invalid v1 (JAR)
|
|
# signature files (META-INF/*.SF/.RSA present but failing v1 verification).
|
|
# Android installs it fine via v2/v3, but Obtainium parses the APK's legacy v1
|
|
# certificate at install time, gets an empty cert list, and crashes with
|
|
# "RangeError (length): Invalid value: valid value range is empty: 0".
|
|
# minSdk is 26 (solitaire_app/android/AndroidManifest.xml), so v1/JAR signing is
|
|
# not needed at all — disable it and ship a clean v2+v3 signature, matching what
|
|
# modern Android tooling produces for minSdk >= 24.
|
|
"$BT/apksigner" sign \
|
|
--ks "$KEYSTORE" \
|
|
--ks-pass "pass:$KEYSTORE_PASS" \
|
|
--ks-key-alias "$KEY_ALIAS" \
|
|
--key-pass "pass:$KEY_PASS" \
|
|
--min-sdk-version 26 \
|
|
--v1-signing-enabled false \
|
|
--v2-signing-enabled true \
|
|
--v3-signing-enabled true \
|
|
--out "$APK_OUT" \
|
|
"$STAGING/app-aligned.apk"
|
|
|
|
echo ">>> verify"
|
|
"$BT/apksigner" verify --min-sdk-version 26 --verbose "$APK_OUT"
|
|
|
|
# Guard: no leftover v1/JAR signature files may remain — their presence (valid or
|
|
# not) is what tripped Obtainium. Fail the build if any slipped through.
|
|
if unzip -l "$APK_OUT" 2>/dev/null | grep -qiE 'META-INF/.*\.(SF|RSA|DSA|EC)$'; then
|
|
echo "ERROR: APK still contains v1/JAR signature files; expected v2+v3 only" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo ">>> done: $APK_OUT"
|