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