#!/usr/bin/env bash # ======================================== # Shared Alarm Test Library # ======================================== # # Common helpers for Phase 1, 2, and 3 test scripts # # Usage: source this file in your test script: # SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # source "${SCRIPT_DIR}/alarm-test-lib.sh" # # Configuration can be overridden before sourcing: # APP_ID="custom.package" source "${SCRIPT_DIR}/alarm-test-lib.sh" # # STRICT MODE NOTE: # This library does NOT set strict mode itself (set -euo pipefail) because # it's a library file. Scripts that source this library SHOULD set strict mode: # set -euo pipefail # IFS=$'\n\t' # --- Config Defaults (can be overridden before sourcing) --- : "${APP_ID:=org.timesafari.dailynotification}" : "${APK_PATH:=./app/build/outputs/apk/debug/app-debug.apk}" : "${ADB_BIN:=adb}" # Reactivation log tag (common across phases) : "${REACTIVATION_TAG:=DNP-REACTIVATION}" : "${SCENARIO_KEY:=Detected scenario: }" # Screenshot configuration : "${SCREENSHOT_ROOT:=screenshots}" : "${ENABLE_SCREENSHOTS:=1}" # Run folder configuration (P1) : "${RUN_ID:=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || echo 'unknown')}" : "${RUN_DIR:=runs/${RUN_ID}}" # Release gating configuration (P4) : "${RELEASE_GATE_PHASE3:=0}" # Derived config (for backward compatibility with Phase 1) PACKAGE="${APP_ID}" ACTIVITY="${APP_ID}/.MainActivity" # Colors for output (used by Phase 1 print_* functions) RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' # No Color # ======================================== # PUBLIC API - UI/Log Helpers # ======================================== # These are the primary functions that all scripts should use. # Deprecated functions (print_*, wait_for_*) are kept for backward compatibility. # ======================================== # Event Emission (Phase B - Live Console Updates) # ======================================== # Initialize run ID for event emission : "${DNP_RUN_ID:=$(date '+%Y%m%d_%H%M%S' 2>/dev/null || echo 'unknown')}" # Emit test event to console via ADB broadcast emit_event() { # Usage: emit_event TYPE LEVEL [PHASE] [TEST] [STEP] [MESSAGE] [EXTRA_JSON] # Example: emit_event "step_start" "INFO" "phase1" "phase1_test0" "p1_t0_s1" "Starting step" local event_type="$1" local level="${2:-INFO}" local phase_id="${3:-}" local test_id="${4:-}" local step_id="${5:-}" local message="${6:-}" local extra_json="${7:-}" # Only emit if UI events enabled if [ "${DNP_UI_EVENTS:-0}" != "1" ]; then return 0 fi # Build JSON payload using Python (safer than shell JSON escaping) local payload payload=$(python3 -c " import json import sys from datetime import datetime event = { 'version': 'testevent.v1', 'ts': datetime.now().isoformat(), 'runId': '${DNP_RUN_ID}', 'type': '${event_type}', 'level': '${level}', 'phaseId': '${phase_id}', 'testId': '${test_id}', 'stepId': '${step_id}', 'message': '${message}' } # Add extra JSON fields if provided if '${extra_json}': try: extra = json.loads('${extra_json}') event.update(extra) except: pass print(json.dumps(event)) " 2>/dev/null) if [ -z "$payload" ]; then # Fallback: simple JSON without Python payload="{\"version\":\"testevent.v1\",\"ts\":\"$(date -Iseconds 2>/dev/null || date '+%Y-%m-%dT%H:%M:%S')\",\"runId\":\"${DNP_RUN_ID}\",\"type\":\"${event_type}\",\"level\":\"${level}\",\"phaseId\":\"${phase_id}\",\"testId\":\"${test_id}\",\"stepId\":\"${step_id}\",\"message\":\"${message}\"}" fi # Send via ADB broadcast adb_broadcast_event "$payload" } # Send event via ADB broadcast adb_broadcast_event() { local payload="$1" local action="org.timesafari.dailynotification.TEST_EVENT" # Escape payload for shell (single quotes are safest) # Replace single quotes with '\'' (end quote, escaped quote, start quote) local escaped_payload escaped_payload=$(echo "$payload" | sed "s/'/'\\\\''/g") # Send broadcast $ADB_BIN shell am broadcast \ -a "$action" \ --es payload "$escaped_payload" \ >/dev/null 2>&1 || true } # Set test context (phase/test/step) for event emission set_test_context() { # Usage: set_test_context PHASE_ID TEST_ID STEP_ID export DNP_PHASE="${1:-}" export DNP_TEST="${2:-}" export DNP_STEP="${3:-}" } # Emit step start event step_start() { # Usage: step_start STEP_ID [MESSAGE] local step_id="${1:-${DNP_STEP}}" local message="${2:-Starting step}" if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then emit_event "step_start" "INFO" "${DNP_PHASE:-}" "${DNP_TEST:-}" "$step_id" "$message" fi } # Emit step pass event step_pass() { # Usage: step_pass STEP_ID [MESSAGE] local step_id="${1:-${DNP_STEP}}" local message="${2:-Step completed}" if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then emit_event "step_pass" "INFO" "${DNP_PHASE:-}" "${DNP_TEST:-}" "$step_id" "$message" fi } # Emit step warn event step_warn() { # Usage: step_warn STEP_ID [MESSAGE] local step_id="${1:-${DNP_STEP}}" local message="${2:-Step warning}" if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then emit_event "step_warn" "WARN" "${DNP_PHASE:-}" "${DNP_TEST:-}" "$step_id" "$message" fi } # Emit step fail event step_fail() { # Usage: step_fail STEP_ID [MESSAGE] local step_id="${1:-${DNP_STEP}}" local message="${2:-Step failed}" if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then emit_event "step_fail" "ERROR" "${DNP_PHASE:-}" "${DNP_TEST:-}" "$step_id" "$message" fi } section() { echo echo "========================================" echo "$1" echo "========================================" echo } substep() { echo "→ $1" } info() { echo -e "ℹ️ $1" } ok() { echo -e "✅ $1" } warn() { echo -e "⚠️ $1" } error() { echo -e "❌ $1" } pause() { echo read -rp "Press Enter when ready to continue..." echo } ui_prompt() { echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "👆 UI ACTION REQUIRED" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo -e "$1" echo # Emit operator_required event if UI events enabled if [ "${DNP_UI_EVENTS:-0}" = "1" ]; then emit_event "operator_required" "WAIT" "${DNP_PHASE:-}" "${DNP_TEST:-}" "${DNP_STEP:-}" "$1" fi read -rp "Press Enter after completing the action above..." echo } # ======================================== # PUBLIC API - Command Execution Helpers # ======================================== run_cmd() { # Execute a command and capture output # Usage: run_cmd "description" command [args...] # Returns: exit code of command local desc="$1" shift local cmd=("$@") info "Running: $desc" if "${cmd[@]}"; then ok "$desc completed" return 0 else local exit_code=$? error "$desc failed (exit code: $exit_code)" return $exit_code fi } require_cmd() { # Execute a command and exit on failure # Usage: require_cmd "description" command [args...] # Exits script if command fails local desc="$1" shift local cmd=("$@") info "Required: $desc" if ! "${cmd[@]}"; then local exit_code=$? error "$desc failed (exit code: $exit_code)" exit $exit_code fi ok "$desc completed" } # ======================================== # DEPRECATED - Phase 1 Compatibility Aliases # ======================================== # These functions are kept for backward compatibility but should not be used # in new code. Use the public API functions above instead. print_header() { # DEPRECATED: Use section() instead echo "" echo -e "${BLUE}========================================${NC}" echo -e "${BLUE}$1${NC}" echo -e "${BLUE}========================================${NC}" echo "" } print_step() { # DEPRECATED: Use substep() instead echo -e "${GREEN}→ Step $1:${NC} $2" } print_wait() { # DEPRECATED: Use info() or warn() instead echo -e "${YELLOW}⏳ $1${NC}" } print_success() { # DEPRECATED: Use ok() instead echo -e "${GREEN}✅ $1${NC}" } print_error() { # DEPRECATED: Use error() instead echo -e "${RED}❌ $1${NC}" } print_info() { # DEPRECATED: Use info() instead echo -e "${BLUE}ℹ️ $1${NC}" } print_warn() { # DEPRECATED: Use warn() instead echo -e "${YELLOW}⚠️ $1${NC}" } wait_for_user() { # DEPRECATED: Use pause() instead echo "" read -p "Press Enter when ready to continue..." echo "" } wait_for_ui_action() { # DEPRECATED: Use ui_prompt() instead ui_prompt "$1" } # --- ADB/Build Helpers --- require_adb_device() { section "Pre-Flight Checks" if ! $ADB_BIN devices | awk 'NR>1 && $2=="device"{found=1} END{exit !found}'; then error "No emulator/device in 'device' state. Start your emulator first." exit 1 fi ok "ADB device connected" info "Checking emulator status..." if ! $ADB_BIN shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then info "Waiting for emulator to boot..." $ADB_BIN wait-for-device while [ "$($ADB_BIN shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do sleep 2 done fi ok "Emulator is ready" } # Phase 1 compatibility: check_adb_connection + check_emulator_ready check_adb_connection() { if ! $ADB_BIN devices | grep -q "device$"; then print_error "No Android device/emulator connected" echo "Please connect a device or start an emulator, then run:" echo " adb devices" exit 1 fi print_success "ADB device connected" } check_emulator_ready() { print_info "Checking emulator status..." if ! $ADB_BIN shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; then print_wait "Waiting for emulator to boot..." $ADB_BIN wait-for-device while [ "$($ADB_BIN shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do sleep 2 done fi print_success "Emulator is ready" } build_app() { section "Building Test App" substep "Step 1: Building debug APK..." if ./gradlew :app:assembleDebug; then ok "Build successful" else error "Build failed" exit 1 fi if [[ -f "$APK_PATH" ]]; then ok "APK ready: $APK_PATH" else error "APK not found at $APK_PATH" exit 1 fi } install_app() { section "Installing App" substep "Step 1: Uninstalling existing app (if present)..." set +e uninstall_output="$($ADB_BIN uninstall "$APP_ID" 2>&1)" uninstall_status=$? set -e if [[ $uninstall_status -ne 0 ]]; then if grep -q "DELETE_FAILED_INTERNAL_ERROR" <<<"$uninstall_output"; then info "No existing app to uninstall (continuing)" else warn "Uninstall returned non-zero status: $uninstall_output (continuing anyway)" fi else ok "Previous app uninstall succeeded" fi substep "Step 2: Installing new APK..." if $ADB_BIN install -r "$APK_PATH"; then ok "App installed successfully" else error "App installation failed" exit 1 fi substep "Step 3: Verifying installation..." if $ADB_BIN shell pm list packages | grep -q "$APP_ID"; then ok "App verified in package list" else error "App not found in package list" exit 1 fi info "Clearing logcat buffer..." $ADB_BIN logcat -c ok "Logcat cleared" } launch_app() { # Check if we're in Phase 1 context (has print_info function) if type print_info >/dev/null 2>&1; then print_info "Launching app..." $ADB_BIN shell am start -n "${ACTIVITY}" sleep 3 # Give app time to load and check status print_success "App launched" else substep "Launching app main activity..." $ADB_BIN shell am start -n "${ACTIVITY}" >/dev/null 2>&1 sleep 3 # Give app time to load ok "App launched" fi } clear_logs() { info "Clearing logcat buffer..." $ADB_BIN logcat -c ok "Logs cleared" } show_alarms() { info "Checking AlarmManager status..." echo $ADB_BIN shell dumpsys alarm | grep -A3 "$APP_ID" || true echo } # Plugin-specific alarm action (must match AndroidManifest.xml) PLUGIN_ALARM_ACTION="org.timesafari.daily.NOTIFICATION" get_plugin_alarm_count() { # Returns count of ONLY the plugin's NOTIFICATION alarms (not prefetch - that uses WorkManager) # Expected: 1 notification alarm per daily schedule # # This function counts ALARM_CLOCK wake alarms (RTC_WAKEUP) tagged as: # tag=*walarm*:org.timesafari.daily.NOTIFICATION # # Uses deduplicating parser to avoid double-counting the same alarm that appears in both: # - Main alarm list # - "Next wake from idle" section (ignored - only counts RTC_WAKEUP blocks) # - Alarm Stats section (ignored - only counts actual alarm blocks) # # Tracks unique Alarm handles to ensure each alarm is counted only once. # Checks for app package AND action string anywhere in the block (they appear on different lines). local count app_id action app_id="$APP_ID" action="org.timesafari.daily.NOTIFICATION" count="$($ADB_BIN shell dumpsys alarm 2>/dev/null | awk -v app="$app_id" -v action="$action" ' BEGIN { in_block = 0 alarmId = "" hasAppLine = 0 hasActionLine = 0 } # Start of a new RTC_WAKEUP alarm block /^[[:space:]]*RTC_WAKEUP/ { # Flush previous block if it was a plugin notification if (in_block == 1 && hasAppLine == 1 && hasActionLine == 1 && alarmId != "") { seen[alarmId] = 1 } in_block = 1 alarmId = "" hasAppLine = 0 hasActionLine = 0 # Extract alarmId from "Alarm{11245c ..." if (match($0, /Alarm\{[0-9a-f]+/)) { # match is like "Alarm{11245c", extract just the hex part alarmId = substr($0, RSTART + 6, RLENGTH - 6) } } # Blank line = end of block /^[[:space:]]*$/ { if (in_block == 1 && hasAppLine == 1 && hasActionLine == 1 && alarmId != "") { seen[alarmId] = 1 } in_block = 0 alarmId = "" hasAppLine = 0 hasActionLine = 0 } # Lines inside an alarm block in_block == 1 { if ($0 ~ app) { hasAppLine = 1 } if ($0 ~ action) { hasActionLine = 1 } } END { # Flush final block if it was a plugin notification if (in_block == 1 && hasAppLine == 1 && hasActionLine == 1 && alarmId != "") { seen[alarmId] = 1 } count = 0 for (id in seen) { count++ } print count + 0 } ' 2>/dev/null || echo "0")" echo "$count" | tr -d '\n\r' | head -1 } # Alias for backward compatibility get_notification_alarm_count() { get_plugin_alarm_count } get_prefetch_work_count() { # Returns count of prefetch WorkManager jobs (not AlarmManager alarms) # Note: Prefetch uses WorkManager, not AlarmManager, so it won't appear in dumpsys alarm # This is a placeholder - actual WorkManager job counting would require different approach local count count="$($ADB_BIN shell dumpsys jobscheduler | grep -c "prefetch" 2>/dev/null || echo "0")" echo "$count" | tr -d '\n\r' | head -1 } get_system_alarm_count() { # Returns total RTC_WAKEUP alarms on system (for debugging/context) local count count="$($ADB_BIN shell dumpsys alarm 2>/dev/null | grep -c "RTC_WAKEUP" || echo "0")" echo "$count" | tr -d '\n\r' | head -1 } count_alarms() { # Legacy function: now returns plugin-specific alarm count # Use get_plugin_alarm_count() for clarity, or get_system_alarm_count() for total get_plugin_alarm_count } show_plugin_alarms_compact() { # Prints only the plugin's alarm block(s) for debugging # Shows complete RTC_WAKEUP alarm blocks that contain the app ID # This makes it visually obvious why the AWK matcher should pick up alarms # (app ID on header line, action on tag line within the same block) $ADB_BIN shell dumpsys alarm 2>/dev/null \ | awk -v app="$APP_ID" ' BEGIN { in_block = 0 block = "" found_app = 0 } /^[[:space:]]*RTC_WAKEUP/ { # Print previous block if it contained app ID if (in_block && found_app) { print block ORS } # Start new block in_block = 1 block = $0 ORS found_app = ($0 ~ app) ? 1 : 0 next } /^[[:space:]]*$/ { # End of block if (in_block && found_app) { print block ORS } in_block = 0 block = "" found_app = 0 next } { if (in_block) { block = block $0 ORS if ($0 ~ app) { found_app = 1 } } } END { if (in_block && found_app) { print block } } ' \ | sed -n '1,80p' || true } wait_for_stable_plugin_alarm_count() { # Polls for plugin alarm count to stabilize (reduces race condition false negatives) # Usage: wait_for_stable_plugin_alarm_count [attempts] [delay_seconds] # Default: 5 attempts, 2 second delay (total ~10 seconds) # Returns: alarm count (0 if none found after all attempts) local attempts=${1:-5} local delay=${2:-2} local count=0 local i for i in $(seq 1 "$attempts"); do count="$(get_plugin_alarm_count)" if [ "$count" -ge 1 ] 2>/dev/null; then echo "$count" return 0 fi if [ "$i" -lt "$attempts" ]; then sleep "$delay" fi done echo "$count" } # --- Screenshot Helpers --- take_screenshot() { # Captures a device screenshot and saves it with test name, step, and timestamp # Usage: take_screenshot "test_name" "step_name" # Example: take_screenshot "phase1_test0_daily_rollover" "before_scheduling" local test_name="$1" local step_name="$2" # Do nothing if screenshots are disabled if [ "$ENABLE_SCREENSHOTS" != "1" ]; then return 0 fi if [ -z "$ADB_BIN" ]; then echo "⚠️ ADB_BIN is not set; cannot take screenshot." >&2 return 0 fi # Timestamp for uniqueness local ts ts="$(date '+%Y%m%d-%H%M%S' 2>/dev/null)" || ts="unknown" # Directory: screenshots// # Use absolute path relative to script directory if SCREENSHOT_ROOT is relative local dir if [ -n "$SCRIPT_DIR" ] && [ -d "$SCRIPT_DIR" ]; then dir="${SCRIPT_DIR}/${SCREENSHOT_ROOT}/${test_name}" elif [ -n "${BASH_SOURCE[0]}" ]; then # Fallback: derive from this script's location local lib_dir lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || lib_dir="" if [ -n "$lib_dir" ]; then dir="${lib_dir}/${SCREENSHOT_ROOT}/${test_name}" else dir="${SCREENSHOT_ROOT}/${test_name}" fi else dir="${SCREENSHOT_ROOT}/${test_name}" fi mkdir -p "$dir" 2>/dev/null || { echo "⚠️ Failed to create screenshot directory: $dir" >&2 return 0 } # File name: __.png, with spaces converted to dashes local safe_step="${step_name// /-}" local file="${dir}/${test_name}_${safe_step}_${ts}.png" echo "📸 Capturing screenshot: ${file}" >&2 # Use exec-out to avoid newline mangling if ! "$ADB_BIN" exec-out screencap -p > "$file" 2>/dev/null; then echo "⚠️ Failed to capture screenshot via adb." >&2 # Clean up empty file if created [ -s "$file" ] || rm -f "$file" 2>/dev/null || true return 0 fi # Verify file was created and has content if [ ! -s "$file" ]; then echo "⚠️ Screenshot file is empty or missing: $file" >&2 rm -f "$file" 2>/dev/null || true return 0 fi } take_failure_screenshot() { # Convenience wrapper for failure cases # Usage: take_failure_screenshot "test_name" "reason" # Example: take_failure_screenshot "phase1_test0_daily_rollover" "no_alarm_after_rollover" local test_name="$1" local reason="$2" take_screenshot "$test_name" "FAIL_${reason}" } force_stop_app() { info "Forcing stop of app process..." $ADB_BIN shell am force-stop "$APP_ID" || true sleep 2 ok "Force stop issued" } # Phase 1 compatibility alias kill_app() { print_info "Killing app process..." $ADB_BIN shell am kill "$APP_ID" sleep 2 # Verify process is killed if $ADB_BIN shell ps | grep -q "$APP_ID"; then print_wait "Process still running, using force-stop..." $ADB_BIN shell am force-stop "$APP_ID" sleep 1 fi if ! $ADB_BIN shell ps | grep -q "$APP_ID"; then print_success "App process terminated" else print_error "App process still running" return 1 fi } reboot_emulator() { info "Rebooting emulator..." $ADB_BIN reboot ok "Reboot initiated" info "Waiting for emulator to come back online..." $ADB_BIN wait-for-device info "Waiting for system to fully boot..." while [ "$($ADB_BIN shell getprop sys.boot_completed 2>/dev/null)" != "1" ]; do sleep 2 done sleep 5 # Additional buffer for system services ok "Emulator back online" } # --- Log Parsing Helpers --- get_recovery_logs() { # Collect only the relevant reactivation logs $ADB_BIN logcat -d | grep "$REACTIVATION_TAG" || true } extract_field_from_logs() { # Usage: extract_field_from_logs "$logs" "rescheduled" local logs="$1" local key="$2" echo "$logs" | grep -E "$key=" | sed -E "s/.*$key=([0-9]+).*/\1/" | tail -n1 || echo "0" } extract_scenario_from_logs() { local logs="$1" echo "$logs" | grep -E "$SCENARIO_KEY" | sed -E "s/.*$SCENARIO_KEY([A-Z_]+).*/\1/" | tail -n1 || echo "" } # Phase 1 compatibility: check_recovery_logs check_recovery_logs() { print_info "Checking recovery logs..." echo "" $ADB_BIN logcat -d | grep -E "$REACTIVATION_TAG" | tail -10 echo "" } # Phase 1 compatibility: check_alarm_status check_alarm_status() { print_info "Checking AlarmManager status..." echo "" $ADB_BIN shell dumpsys alarm | grep -i "$APP_ID" | head -5 echo "" } # --- Test Selection Helper --- should_run_test() { # Usage: should_run_test "1" SELECTED_TESTS # Returns 0 (success) if test should run, 1 (failure) if not local id="$1" local -n selected_tests_ref="$2" # If no tests are specified, run all tests if [[ "${#selected_tests_ref[@]}" -eq 0 ]]; then return 0 fi for t in "${selected_tests_ref[@]}"; do if [[ "$t" == "$id" ]]; then return 0 fi done return 1 } # ======================================== # PUBLIC API - Run Folder & Evidence Helpers (P1) # ======================================== ensure_run_dir() { # Create run directory structure if it doesn't exist # Creates: RUN_DIR/logs, RUN_DIR/alarms, RUN_DIR/screens, RUN_DIR/notes # Returns: 0 on success, 1 on failure local base_dir if [ -n "$SCRIPT_DIR" ] && [ -d "$SCRIPT_DIR" ]; then base_dir="${SCRIPT_DIR}/${RUN_DIR}" elif [ -n "${BASH_SOURCE[0]}" ]; then local lib_dir lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd 2>/dev/null)" || lib_dir="" if [ -n "$lib_dir" ]; then base_dir="${lib_dir}/${RUN_DIR}" else base_dir="${RUN_DIR}" fi else base_dir="${RUN_DIR}" fi mkdir -p "${base_dir}/logs" "${base_dir}/alarms" "${base_dir}/screens" "${base_dir}/notes" 2>/dev/null || { error "Failed to create run directory: ${base_dir}" return 1 } # Export for use by capture functions export RUN_DIR_ABS="${base_dir}" return 0 } get_run_dir() { # Get absolute path to current run directory # Returns: absolute path, or empty string if not initialized echo "${RUN_DIR_ABS:-}" } capture_alarms() { # Capture AlarmManager dump to run folder # Usage: capture_alarms "