diff --git a/scripts/ANDROID_TESTING.md b/scripts/ANDROID_TESTING.md new file mode 100644 index 0000000..f5f1231 --- /dev/null +++ b/scripts/ANDROID_TESTING.md @@ -0,0 +1,228 @@ +# Android testing + +This directory contains lightweight Android test helpers for Ferrous Solitaire. +They are intended to run against either a physical Android device or an emulator +connected through `adb`. When no device is connected the smoke script can +automatically launch an AVD for you. + +## Prerequisites + +- Android SDK and NDK installed. +- `adb` available on `PATH`. +- One device/emulator visible in `adb devices`, **or** at least one AVD created + (the script will launch one automatically if `LAUNCH_AVD=1`, which is the default). +- If multiple devices are connected, set `ADB_SERIAL` to the target device serial. +- Environment variables required by `scripts/build_android_apk.sh` when building: + +```sh +export ANDROID_HOME=/path/to/android-sdk +export ANDROID_NDK_HOME=/path/to/android-ndk +export BUILD_TOOLS_VERSION=34.0.0 +export PLATFORM=android-34 +``` + +## Smoke test + +From the workspace root (`Rusty_Solitaire/`): + +```sh +scripts/android_smoke.sh +``` + +The smoke test first checks whether `adb` can see a ready device. If no device +is connected and `LAUNCH_AVD=1` (default), it: + +1. locates the `emulator` binary under `ANDROID_HOME` or `PATH`, +2. picks the first available AVD (or uses `AVD_NAME`), +3. launches the emulator in the foreground (or headless with `AVD_HEADLESS=1`), +4. waits for `sys.boot_completed=1` before proceeding, +5. dismisses the lock screen so the screenshot shows the app. + +Once a device is ready (auto-launched or pre-existing) the script: + +1. builds the APK using `scripts/build_android_apk.sh`, +2. installs it with `adb install -r -d` so debug smoke builds can replace newer local builds, +3. force-stops the package by default for a clean launch, +4. clears `logcat`, +5. launches `com.ferrousapp.solitaire/android.app.NativeActivity`, +6. waits for the app to settle, +7. verifies the process is still running, +8. captures a screenshot and `logcat`, and +9. fails on fatal log patterns such as native crashes, JNI fatal errors, real ANRs, + and Rust panics. + +On exit the script kills any emulator it launched (`SHUTDOWN_AVD_ON_EXIT=1` by +default). Set `SHUTDOWN_AVD_ON_EXIT=0` to keep the emulator open for inspection. + +Artifacts are written to `target/android-smoke//` by default. A successful run includes: + +- `device.txt` — selected device and display metadata, +- `df-data-before.txt` / `df-data-after.txt` — emulator/device storage snapshots, +- `emulator.log` — stdout/stderr from the emulator process (AVD runs only), +- `emulator.pid` — PID of the emulator process (AVD runs only), +- `launch.png` — screenshot after the wait period, +- `logcat.txt` — full captured log, +- `log-summary.txt` — grep summary for warnings, errors, JNI, safe-area, and crash terms, and +- `pid.txt` — running app process id. + +## Creating an AVD + +If no AVDs exist, create one before running the smoke test: + +```sh +# Install a system image +"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \ + 'system-images;android-34;google_apis;x86_64' + +# Create the AVD +"$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd \ + -n Pixel_7_API_34 \ + -k 'system-images;android-34;google_apis;x86_64' \ + --device 'pixel_7' +``` + +Then run the smoke test — it will pick `Pixel_7_API_34` automatically: + +```sh +scripts/android_smoke.sh +``` + +## Faster iteration + +If you already built the APK and only want to reinstall/relaunch: + +```sh +BUILD_APK=0 scripts/android_smoke.sh +``` + +If the APK is already installed and you only want to relaunch/capture logs: + +```sh +BUILD_APK=0 INSTALL_APK=0 scripts/android_smoke.sh +``` + +By default the script force-stops the package before launch so logcat and screenshots represent a clean app start. To test warm-launch behavior instead: + +```sh +BUILD_APK=0 INSTALL_APK=0 FORCE_STOP=0 scripts/android_smoke.sh +``` + +This is also useful when an already-installed build is good enough for launch/log checks. On install failure, the script writes `adb-install.txt`, storage snapshots, and installed-package diagnostics to the output directory. + +If install fails with `INSTALL_FAILED_UPDATE_INCOMPATIBLE`, the smoke script uninstalls the package and retries once by default (`RESET_ON_SIGNATURE_MISMATCH=1`). This resets app data on the device/emulator. Disable it with: + +```sh +RESET_ON_SIGNATURE_MISMATCH=0 scripts/android_smoke.sh +``` + +To write artifacts to a stable path: + +```sh +OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh +``` + +When reusing an output directory, previous files are removed by default so stale artifacts do not contaminate the latest result. To keep existing files: + +```sh +CLEAN_OUT_DIR=0 OUT_DIR=target/android-smoke/latest scripts/android_smoke.sh +``` + +To target a specific device when more than one is attached: + +```sh +ADB_SERIAL=emulator-5554 scripts/android_smoke.sh +``` + +To wait longer for safe-area inset polling or slow devices: + +```sh +WAIT_SECS=8 scripts/android_smoke.sh +``` + +## AVD options + +To pick a specific AVD by name instead of auto-selecting the first one: + +```sh +AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh +``` + +To run headless (no emulator window) — useful in CI or on a display-less machine: + +```sh +AVD_HEADLESS=1 scripts/android_smoke.sh +``` + +To give a slow machine more time to boot the emulator (default is 120 s): + +```sh +AVD_BOOT_TIMEOUT=180 scripts/android_smoke.sh +``` + +To keep the emulator running after the test (useful for manual inspection): + +```sh +SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh +``` + +To pass extra flags to the emulator (e.g. disable snapshot for a completely +cold boot, or change GPU mode): + +```sh +AVD_EXTRA_ARGS="-gpu swiftshader_indirect" scripts/android_smoke.sh +``` + +To disable AVD auto-launch entirely and fail immediately if no device is +connected: + +```sh +LAUNCH_AVD=0 scripts/android_smoke.sh +``` + +For build-only validation without requiring a connected device, use the lower-level APK builder directly: + +```sh +scripts/build_android_apk.sh +``` + +For smoke testing, `scripts/android_smoke.sh` defaults to the connected device's primary ABI when `BUILD_APK=1`, which keeps emulator APKs much smaller than the full multi-ABI default. You can still override it explicitly: + +```sh +ABIS=x86_64 scripts/android_smoke.sh +``` + +For build-only validation, `scripts/build_android_apk.sh` still defaults to all configured ABIs unless you set `ABIS` yourself: + +```sh +ABIS=x86_64 scripts/build_android_apk.sh +``` + +The APK builder signs debug builds with a persistent keystore at `target/android/debug.keystore` by default. This avoids signature churn across smoke-test runs. + +The APK builder also strips native debug symbols by default before packaging (`STRIP_NATIVE_LIBS=1`). This keeps debug APKs installable on emulators with limited `/data` storage. To preserve native debug symbols for low-level debugging: + +```sh +STRIP_NATIVE_LIBS=0 ABIS=x86_64 scripts/build_android_apk.sh +``` + +## Device checklist + +The script is only a smoke test. Before shipping Android builds, also verify: + +- safe-area insets arrive and shift the HUD after a few seconds, +- HUD does not overlap the top status bar, +- modal Done buttons are above the gesture/navigation bar, +- stock tap works, +- drag-and-drop works on tableau, waste, and foundation piles, +- Settings/Help/Profile modals open and close, +- login tokens persist after app restart, and +- `target/android-smoke/.../logcat.txt` contains no fatal JNI/native crash output. + +## Notes + +- `adb shell input tap X Y` uses physical pixels, not Bevy logical pixels. +- The project’s common test device mapping is physical `1080×2400`, Bevy logical + `900×2000`, scale factor `1.20`; multiply logical coordinates by `1.20` for + scripted `adb shell input` commands on that device. +- Keep generated screenshots/logs under `target/android-smoke/` so they stay out + of source control. diff --git a/scripts/android_smoke.sh b/scripts/android_smoke.sh new file mode 100755 index 0000000..ea87ae2 --- /dev/null +++ b/scripts/android_smoke.sh @@ -0,0 +1,362 @@ +#!/usr/bin/env bash +# Android smoke test for Ferrous Solitaire. +# +# Builds (optional), installs, launches, captures logcat + screenshot, and +# fails on fatal Android log patterns. Designed as a lightweight device/emulator +# sanity check rather than a full UI automation suite. +# +# Required: +# adb on PATH +# Android SDK/NDK env required by scripts/build_android_apk.sh when BUILD_APK=1 +# +# Optional environment: +# BUILD_APK=1|0 Build APK before install (default: 1) +# INSTALL_APK=1|0 Install APK before launch (default: 1) +# RESET_ON_SIGNATURE_MISMATCH=1|0 +# Uninstall/retry if debug signatures differ (default: 1) +# LAUNCH_APP=1|0 Launch app before checks (default: 1) +# FORCE_STOP=1|0 Force-stop package before launch for clean logs (default: 1) +# CAPTURE_SCREENSHOT=1|0 Capture screenshot (default: 1) +# ADB_SERIAL=... Device serial to use when multiple devices are connected +# APK_PATH=... APK to install (default: target/debug/apk/ferrous-solitaire.apk) +# PACKAGE=... Android package (default: com.ferrousapp.solitaire) +# ACTIVITY=... Activity class (default: android.app.NativeActivity) +# OUT_DIR=... Artifact directory (default: target/android-smoke/) +# CLEAN_OUT_DIR=1|0 Remove prior artifacts from OUT_DIR first (default: 1) +# WAIT_SECS=... Seconds to wait after launch (default: 5) +# ABIS=... Passed to build script. If unset and BUILD_APK=1, +# defaults to the connected device's primary ABI. +# +# AVD auto-launch (used when no device/emulator is already connected): +# LAUNCH_AVD=1|0 Auto-launch an AVD when no device is ready (default: 1) +# AVD_NAME=... AVD name to launch (default: first from `emulator -list-avds`) +# AVD_BOOT_TIMEOUT=... Seconds to wait for the emulator to finish booting (default: 120) +# AVD_HEADLESS=1|0 Run with -no-window -no-audio for CI/no-display environments (default: 0) +# AVD_EXTRA_ARGS=... Extra arguments appended verbatim to the emulator command line +# SHUTDOWN_AVD_ON_EXIT=1|0 +# Kill the AVD this script launched on exit (default: 1). +# Set to 0 to leave the emulator running after the test. +# +# Examples: +# scripts/android_smoke.sh +# BUILD_APK=0 scripts/android_smoke.sh +# LAUNCH_AVD=0 scripts/android_smoke.sh # error out if no device, never auto-launch +# AVD_NAME=Pixel_7_API_34 scripts/android_smoke.sh +# AVD_HEADLESS=1 scripts/android_smoke.sh # CI / no-display +# SHUTDOWN_AVD_ON_EXIT=0 scripts/android_smoke.sh # keep emulator open after test +# OUT_DIR=target/android-smoke/latest WAIT_SECS=8 scripts/android_smoke.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +BUILD_APK="${BUILD_APK:-1}" +INSTALL_APK="${INSTALL_APK:-1}" +RESET_ON_SIGNATURE_MISMATCH="${RESET_ON_SIGNATURE_MISMATCH:-1}" +LAUNCH_APP="${LAUNCH_APP:-1}" +FORCE_STOP="${FORCE_STOP:-1}" +CAPTURE_SCREENSHOT="${CAPTURE_SCREENSHOT:-1}" +APK_PATH="${APK_PATH:-target/debug/apk/ferrous-solitaire.apk}" +PACKAGE="${PACKAGE:-com.ferrousapp.solitaire}" +ACTIVITY="${ACTIVITY:-android.app.NativeActivity}" +WAIT_SECS="${WAIT_SECS:-5}" +OUT_DIR="${OUT_DIR:-target/android-smoke/$(date +%Y%m%d-%H%M%S)}" +CLEAN_OUT_DIR="${CLEAN_OUT_DIR:-1}" +REMOTE_SCREENSHOT="/sdcard/ferrous-solitaire-smoke.png" + +LAUNCH_AVD="${LAUNCH_AVD:-1}" +AVD_NAME="${AVD_NAME:-}" +AVD_BOOT_TIMEOUT="${AVD_BOOT_TIMEOUT:-120}" +AVD_HEADLESS="${AVD_HEADLESS:-0}" +AVD_EXTRA_ARGS="${AVD_EXTRA_ARGS:-}" +SHUTDOWN_AVD_ON_EXIT="${SHUTDOWN_AVD_ON_EXIT:-1}" + +ADB=(adb) +if [ -n "${ADB_SERIAL:-}" ]; then + ADB+=( -s "$ADB_SERIAL" ) +fi + +# PID of any emulator we start so the EXIT trap can clean it up. +_LAUNCHED_EMULATOR_PID="" + +_cleanup_emulator() { + if [ -n "$_LAUNCHED_EMULATOR_PID" ] && [ "$SHUTDOWN_AVD_ON_EXIT" = "1" ]; then + echo ">>> shutdown emulator (PID $_LAUNCHED_EMULATOR_PID)" + kill "$_LAUNCHED_EMULATOR_PID" 2>/dev/null || true + fi +} +trap _cleanup_emulator EXIT + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "missing required command: $1" >&2 + exit 1 + } +} + +mkdir -p "$OUT_DIR" +if [ "$CLEAN_OUT_DIR" = "1" ]; then + find "$OUT_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + +fi +require_cmd adb + +# --------------------------------------------------------------------------- +# Device / emulator availability +# --------------------------------------------------------------------------- +DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)" +if [ "$DEVICE_STATE" != "device" ]; then + if [ "$LAUNCH_AVD" != "1" ]; then + adb devices > "$OUT_DIR/adb-devices.txt" 2>&1 || true + if [ -n "${ADB_SERIAL:-}" ]; then + echo "Android device '$ADB_SERIAL' is not connected/ready (state: ${DEVICE_STATE:-unknown})." >&2 + else + echo "No Android device/emulator is connected and ready." >&2 + fi + echo "Run 'adb devices' or start an emulator, then retry." >&2 + echo "Device list saved to $OUT_DIR/adb-devices.txt" >&2 + exit 1 + fi + + # --- locate emulator binary ----------------------------------------------- + # Priority: ANDROID_HOME env → PATH → common SDK install locations. + _find_sdk_root() { + for candidate in \ + "$HOME/Android/Sdk" \ + "$HOME/Library/Android/sdk" \ + "/opt/android-sdk" \ + "/usr/lib/android-sdk"; do + [ -d "$candidate" ] && echo "$candidate" && return + done + } + + EMULATOR_BIN="" + if [ -n "${ANDROID_HOME:-}" ] && [ -x "$ANDROID_HOME/emulator/emulator" ]; then + EMULATOR_BIN="$ANDROID_HOME/emulator/emulator" + elif command -v emulator >/dev/null 2>&1; then + EMULATOR_BIN="$(command -v emulator)" + else + _SDK_ROOT="$(_find_sdk_root)" + if [ -n "$_SDK_ROOT" ] && [ -x "$_SDK_ROOT/emulator/emulator" ]; then + EMULATOR_BIN="$_SDK_ROOT/emulator/emulator" + fi + fi + + if [ -z "$EMULATOR_BIN" ]; then + echo "No Android device found and 'emulator' binary is not available." >&2 + echo " • Install the Android SDK emulator component, or" >&2 + echo " • Set ANDROID_HOME to your SDK root, or" >&2 + echo " • Start a device/emulator manually then retry with LAUNCH_AVD=0." >&2 + exit 1 + fi + echo ">>> emulator binary: $EMULATOR_BIN" + + # --- select AVD ----------------------------------------------------------- + if [ -z "$AVD_NAME" ]; then + AVD_NAME="$("$EMULATOR_BIN" -list-avds 2>/dev/null | head -n 1 | tr -d '\r')" + if [ -z "$AVD_NAME" ]; then + echo "No AVDs found. Create one first, for example:" >&2 + echo " sdkmanager 'system-images;android-34;google_apis;x86_64'" >&2 + echo " avdmanager create avd -n Pixel_7_API_34 \\" >&2 + echo " -k 'system-images;android-34;google_apis;x86_64' --device 'pixel_7'" >&2 + exit 1 + fi + echo ">>> auto-selected AVD: $AVD_NAME" + fi + + # --- launch emulator ------------------------------------------------------- + EMULATOR_ARGS=( -avd "$AVD_NAME" -no-snapshot-load ) + [ "$AVD_HEADLESS" = "1" ] && EMULATOR_ARGS+=( -no-window -no-audio ) + # Split AVD_EXTRA_ARGS on whitespace only (disable glob expansion). + set -f + # shellcheck disable=SC2206 + [ -n "$AVD_EXTRA_ARGS" ] && EMULATOR_ARGS+=( $AVD_EXTRA_ARGS ) + set +f + + echo ">>> launch emulator: $AVD_NAME" + "$EMULATOR_BIN" "${EMULATOR_ARGS[@]}" > "$OUT_DIR/emulator.log" 2>&1 & + _LAUNCHED_EMULATOR_PID=$! + echo "$_LAUNCHED_EMULATOR_PID" > "$OUT_DIR/emulator.pid" + echo " emulator PID: $_LAUNCHED_EMULATOR_PID" + echo " emulator log: $OUT_DIR/emulator.log" + + # --- wait for adb transport ----------------------------------------------- + # Poll adb get-state (≠ wait-for-device which blocks indefinitely) so we can + # honour AVD_BOOT_TIMEOUT for the whole boot sequence. + echo ">>> waiting for device to appear in adb (timeout: ${AVD_BOOT_TIMEOUT}s)" + _ELAPSED=0 + while true; do + _STATE="$("${ADB[@]}" get-state 2>/dev/null || true)" + if [ "$_STATE" = "device" ] || [ "$_STATE" = "offline" ]; then + break + fi + if [ "$_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then + echo "Device did not appear in adb within ${AVD_BOOT_TIMEOUT}s" >&2 + echo "emulator log:" >&2 + tail -20 "$OUT_DIR/emulator.log" >&2 || true + exit 1 + fi + sleep 3 + _ELAPSED=$(( _ELAPSED + 3 )) + echo " ... ${_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s" + done + + # Capture emulator serial (emulator-5554 etc.) so all subsequent adb calls + # target the right device when ADB_SERIAL was not set by the caller. + if [ -z "${ADB_SERIAL:-}" ]; then + _EMU_SERIAL="$(adb devices 2>/dev/null | awk '/^emulator-/{print $1; exit}' | tr -d '\r')" + if [ -n "$_EMU_SERIAL" ]; then + ADB_SERIAL="$_EMU_SERIAL" + ADB=(adb -s "$ADB_SERIAL") + echo ">>> detected emulator serial: $ADB_SERIAL" + fi + fi + + # --- wait for full Android boot ------------------------------------------- + # adb get-state returning "device" means the transport is up, but the + # Android framework may still be initialising. Poll sys.boot_completed. + echo ">>> waiting for boot_completed (timeout: ${AVD_BOOT_TIMEOUT}s)" + _BOOT_ELAPSED=0 + _BOOT_INTERVAL=5 + while true; do + _BOOT="$("${ADB[@]}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" + if [ "$_BOOT" = "1" ]; then + echo ">>> emulator boot complete" + break + fi + if [ "$_BOOT_ELAPSED" -ge "$AVD_BOOT_TIMEOUT" ]; then + echo "Emulator did not finish booting within ${AVD_BOOT_TIMEOUT}s" >&2 + echo "emulator log:" >&2 + tail -20 "$OUT_DIR/emulator.log" >&2 || true + exit 1 + fi + sleep "$_BOOT_INTERVAL" + _BOOT_ELAPSED=$(( _BOOT_ELAPSED + _BOOT_INTERVAL )) + echo " ... ${_BOOT_ELAPSED}s / ${AVD_BOOT_TIMEOUT}s (boot_completed='${_BOOT}')" + done + + # Dismiss the lock screen so later screencap shows the app, not the keyguard. + "${ADB[@]}" shell input keyevent 82 2>/dev/null || true + + # Final sanity check — device must be fully ready before we proceed. + DEVICE_STATE="$("${ADB[@]}" get-state 2>/dev/null || true)" + if [ "$DEVICE_STATE" != "device" ]; then + echo "Emulator is running but adb state is '${DEVICE_STATE:-unknown}'." >&2 + exit 1 + fi +fi + +# --------------------------------------------------------------------------- +# Device metadata +# --------------------------------------------------------------------------- +{ + echo "adb_serial=${ADB_SERIAL:-default}" + echo "package=$PACKAGE" + echo "activity=$ACTIVITY" + echo "device_state=$DEVICE_STATE" + "${ADB[@]}" shell getprop ro.product.model 2>/dev/null | tr -d '\r' | sed 's/^/product_model=/' + "${ADB[@]}" shell getprop ro.build.version.release 2>/dev/null | tr -d '\r' | sed 's/^/android_release=/' + "${ADB[@]}" shell getprop ro.build.version.sdk 2>/dev/null | tr -d '\r' | sed 's/^/android_sdk=/' + "${ADB[@]}" shell wm size 2>/dev/null | tr -d '\r' | sed 's/^/wm_size=/' + "${ADB[@]}" shell wm density 2>/dev/null | tr -d '\r' | sed 's/^/wm_density=/' +} > "$OUT_DIR/device.txt" +"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-before.txt" 2>&1 || true + +if [ "$BUILD_APK" = "1" ]; then + if [ -z "${ABIS:-}" ]; then + DEVICE_ABI="$("${ADB[@]}" shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r')" + case "$DEVICE_ABI" in + x86_64|arm64-v8a|armeabi-v7a) + export ABIS="$DEVICE_ABI" + ;; + armeabi*) + export ABIS="armeabi-v7a" + ;; + *) + echo "Could not map device ABI '$DEVICE_ABI'; using build script default ABIS." >&2 + ;; + esac + fi + echo ">>> build Android APK${ABIS:+ (ABIS=$ABIS)}" + scripts/build_android_apk.sh +fi + +if [ "$INSTALL_APK" = "1" ]; then + [ -f "$APK_PATH" ] || { + echo "APK not found: $APK_PATH" >&2 + echo "Set APK_PATH or run with BUILD_APK=1." >&2 + exit 1 + } + ls -lh "$APK_PATH" > "$OUT_DIR/apk.txt" + echo ">>> install $APK_PATH" + if ! "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install.txt" 2>&1; then + if [ "$RESET_ON_SIGNATURE_MISMATCH" = "1" ] && grep -q "INSTALL_FAILED_UPDATE_INCOMPATIBLE" "$OUT_DIR/adb-install.txt"; then + echo ">>> signature mismatch; uninstalling $PACKAGE and retrying install" + "${ADB[@]}" uninstall "$PACKAGE" > "$OUT_DIR/adb-uninstall-before-retry.txt" 2>&1 || true + if "${ADB[@]}" install -r -d "$APK_PATH" > "$OUT_DIR/adb-install-retry.txt" 2>&1; then + cat "$OUT_DIR/adb-install-retry.txt" >> "$OUT_DIR/adb-install.txt" + else + cat "$OUT_DIR/adb-install.txt" >&2 + cat "$OUT_DIR/adb-install-retry.txt" >&2 + "${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true + "${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true + echo "APK install retry failed. Diagnostics saved in $OUT_DIR" >&2 + exit 1 + fi + else + cat "$OUT_DIR/adb-install.txt" >&2 + "${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after-install-failure.txt" 2>&1 || true + "${ADB[@]}" shell pm list packages | grep -F "$PACKAGE" > "$OUT_DIR/installed-package.txt" 2>&1 || true + echo "APK install failed. Diagnostics saved in $OUT_DIR" >&2 + echo "If the package is already installed and you only need launch/log checks, retry with INSTALL_APK=0." >&2 + exit 1 + fi + fi +fi + +if [ "$FORCE_STOP" = "1" ]; then + echo ">>> force-stop $PACKAGE" + "${ADB[@]}" shell am force-stop "$PACKAGE" || true +fi + +echo ">>> clear logcat" +"${ADB[@]}" logcat -c + +if [ "$LAUNCH_APP" = "1" ]; then + echo ">>> launch $PACKAGE/$ACTIVITY" + "${ADB[@]}" shell am start -n "$PACKAGE/$ACTIVITY" > "$OUT_DIR/am-start.txt" +fi + +echo ">>> wait ${WAIT_SECS}s" +sleep "$WAIT_SECS" + +PID="$("${ADB[@]}" shell pidof "$PACKAGE" | tr -d '\r' || true)" +if [ -z "$PID" ]; then + "${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt" || true + echo "app process is not running after launch: $PACKAGE" >&2 + echo "logcat saved to $OUT_DIR/logcat.txt" >&2 + exit 1 +fi +echo "$PID" > "$OUT_DIR/pid.txt" + +if [ "$CAPTURE_SCREENSHOT" = "1" ]; then + echo ">>> capture screenshot" + "${ADB[@]}" shell screencap -p "$REMOTE_SCREENSHOT" + "${ADB[@]}" pull "$REMOTE_SCREENSHOT" "$OUT_DIR/launch.png" >/dev/null + "${ADB[@]}" shell rm -f "$REMOTE_SCREENSHOT" >/dev/null 2>&1 || true +fi + +echo ">>> capture logcat" +"${ADB[@]}" logcat -d > "$OUT_DIR/logcat.txt" +grep -iE "panic|fatal|jni|native crash|\bANR\b|exception|error|warn|keystore|safe_area" "$OUT_DIR/logcat.txt" > "$OUT_DIR/log-summary.txt" || true +"${ADB[@]}" shell df -h /data > "$OUT_DIR/df-data-after.txt" 2>&1 || true + +# Fatal patterns only. Avoid matching generic "error" because Android logs are +# noisy and many non-fatal framework lines contain that word. +if grep -iE "fatal exception|jni detected error|native crash|signal [0-9]+|ANR in|Application Not Responding|Input dispatching timed out|thread exiting with uncaught exception|panicked at" "$OUT_DIR/logcat.txt"; then + echo "Android smoke test found fatal log output" >&2 + echo "Artifacts saved in $OUT_DIR" >&2 + exit 1 +fi + +echo ">>> Android smoke test passed" +echo "Artifacts saved in $OUT_DIR" diff --git a/scripts/build_android_apk.sh b/scripts/build_android_apk.sh index 99df4ab..39c5119 100755 --- a/scripts/build_android_apk.sh +++ b/scripts/build_android_apk.sh @@ -6,11 +6,15 @@ # ndk-build crate that we couldn't isolate; running each Android toolchain # step explicitly gives us a debuggable pipeline. # -# Required environment: -# ANDROID_HOME Path to Android SDK root -# ANDROID_NDK_HOME Path to the specific NDK version -# BUILD_TOOLS_VERSION e.g. "34.0.0" -# PLATFORM e.g. "android-34" +# 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" @@ -19,7 +23,8 @@ # 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) -# KEYSTORE Path to keystore for signing (default: generates a debug keystore) +# 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) @@ -28,18 +33,63 @@ # $APK_OUT Signed, zipaligned APK set -euo pipefail -: "${ANDROID_HOME:?ANDROID_HOME must be set}" -: "${ANDROID_NDK_HOME:?ANDROID_NDK_HOME must be set}" -: "${BUILD_TOOLS_VERSION:?BUILD_TOOLS_VERSION must be set}" -: "${PLATFORM:?PLATFORM must be set (e.g. android-34)}" +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" @@ -69,6 +119,24 @@ 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" @@ -120,11 +188,15 @@ rm -f "$STAGING/app-unsigned.apk" # --- 5. sign --------------------------------------------------------------- if [ -z "${KEYSTORE:-}" ]; then - # Generate a deterministic debug keystore on the fly. - KEYSTORE="$STAGING/debug.keystore" - KEYSTORE_PASS="${KEYSTORE_PASS:-android}" - KEY_ALIAS="${KEY_ALIAS:-androiddebugkey}" - KEY_PASS="${KEY_PASS:-$KEYSTORE_PASS}" + 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" \