#!/usr/bin/env bash # Build / install / run helper for the Wear OS app. # # Usage: # ./run.sh [args] # # A target prefix is REQUIRED for every device-bound subcommand. The script # never silently picks "the first attached device" — if more than one candidate # exists it asks, and if ADB_SERIAL/WEAR_AVD pins a choice it uses that. # # Target prefix (or DOCTATE_TARGET env var): # watch Real Pixel Watch over ADB-WiFi. Uses the cached serial # from .watch_serial when it's attached; otherwise menu. # Points the build at the dev laptop's LAN URL # ($DOCTATE_DEV_SERVER). # emulator Wear OS AVD on this host. # - 0 emulators running → menu of AVDs from `emulator -list-avds` # - ≥1 running → menu of running serials + "Start a new AVD" # The script never auto-picks a running emulator. To bypass the # menu, pin the choice via ADB_SERIAL= or WEAR_AVD=. # Build keeps its emulator default (10.0.2.2:3000). # # Subcommands that need a target: # (none) install + start (default inner loop) # build ./gradlew :app:assembleDebug # install ./gradlew :app:installDebug # start adb shell am start ... # stop adb shell am force-stop ... # logcat adb logcat, filtered to the app's PID # shot [path] screencap to PNG (default /tmp/wear_screen.png) # test run JVM unit tests, plus instrumented tests on the target # (set SKIP_INSTRUMENTED=1 to skip the on-device tier) # # Target-free subcommands (run without a prefix): # devices list attached devices # connect [host:port] # adb connect to a Pixel Watch over WiFi and remember # the serial in .watch_serial. Without an argument: # reuse the cached serial. # clean ./gradlew clean # help show this help # # Env vars: # DOCTATE_TARGET Same as the prefix (watch|emulator). # The CLI prefix takes precedence when both are set. # ADB_SERIAL Pin a specific device serial. Skips the menu entirely. # DOCTATE_DEV_SERVER # LAN URL of the Axum dev server, used by 'watch' target. # Default: http://192.168.178.27:3000 # DOCTATE_API_KEY If set, passed as -Pdoctate.apiKey=... to Gradle and # baked into BuildConfig.API_KEY at compile time. # Otherwise the value from local.properties is used. # WEAR_AVD AVD name to auto-boot when no emulator is attached # (skips the AVD menu; useful for CI / scripted runs). # SKIP_INSTRUMENTED set to 1 to limit `test` to JVM unit tests (no device needed). set -euo pipefail # --- Config ---------------------------------------------------------------- PACKAGE="com.doctate.watch" ACTIVITY=".presentation.MainActivity" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WATCH_SERIAL_FILE="$SCRIPT_DIR/.watch_serial" DEFAULT_DEV_SERVER="http://192.168.178.27:3000" # Populated by apply_target / parsed args. Empty = no override. ADB_SERIAL="${ADB_SERIAL:-}" DOCTATE_TARGET="${DOCTATE_TARGET:-}" GRADLE_PROPS=() # --- Output helpers -------------------------------------------------------- if [[ -t 1 ]]; then C_INFO=$'\e[1;34m'; C_OK=$'\e[1;32m'; C_ERR=$'\e[1;31m'; C_OFF=$'\e[0m' else C_INFO=""; C_OK=""; C_ERR=""; C_OFF="" fi info() { printf "%s==>%s %s\n" "$C_INFO" "$C_OFF" "$*"; } ok() { printf "%s OK%s %s\n" "$C_OK" "$C_OFF" "$*"; } die() { printf "%sERR%s %s\n" "$C_ERR" "$C_OFF" "$*" >&2; exit 1; } # --- Toolchain detection --------------------------------------------------- detect_java() { if [[ -n "${JAVA_HOME:-}" && -x "$JAVA_HOME/bin/java" ]]; then return fi for candidate in /opt/android-studio/jbr /usr/lib/jvm/java-21-openjdk; do if [[ -x "$candidate/bin/java" ]]; then export JAVA_HOME="$candidate" return fi done die "No JDK found. Set JAVA_HOME or install jdk21-openjdk." } detect_adb() { if [[ -n "${ADB:-}" && -x "$ADB" ]]; then return; fi if [[ -x "$HOME/Android/Sdk/platform-tools/adb" ]]; then ADB="$HOME/Android/Sdk/platform-tools/adb" elif command -v adb >/dev/null 2>&1; then ADB="$(command -v adb)" else die "adb not found. Expected ~/Android/Sdk/platform-tools/adb." fi } # Wrapper: calls adb with -s $ADB_SERIAL when set, otherwise plain adb. # Bash idiom ${VAR:+ARG} expands to ARG only if VAR is non-empty. adb_cmd() { "$ADB" ${ADB_SERIAL:+-s "$ADB_SERIAL"} "$@" } # --- Target resolution ----------------------------------------------------- read_cached_watch_serial() { if [[ -f "$WATCH_SERIAL_FILE" ]]; then cat "$WATCH_SERIAL_FILE" | tr -d '\r\n' fi } # Interactive picker. Writes the chosen item to the global PICKED so the # caller doesn't need $(...) (which would swallow `die` inside a subshell). # Aborts (via die) on bad input or when stdin is not a TTY. # Usage: pick_from_list "Prompt" "label1" "label2" ...; use "$PICKED" PICKED="" pick_from_list() { local prompt="$1"; shift local -a items=("$@") if [[ ! -t 0 ]]; then die "$prompt need a TTY to prompt; pin via ADB_SERIAL / WEAR_AVD instead. Items: ${items[*]}" fi info "$prompt" local i for i in "${!items[@]}"; do printf " %d) %s\n" "$((i+1))" "${items[$i]}" done printf " 0) abort\n" local choice read -r -p "Choose: " choice if [[ ! "$choice" =~ ^[0-9]+$ ]]; then die "Invalid choice: '$choice' (expected a number)." fi if [[ "$choice" -eq 0 ]]; then die "Aborted by user." fi if [[ "$choice" -lt 1 || "$choice" -gt "${#items[@]}" ]]; then die "Out of range: $choice (have ${#items[@]} items)." fi PICKED="${items[$((choice-1))]}" } # Lists all running emulator serials (one per line). list_emulator_serials() { detect_adb "$ADB" devices | awk 'NR>1 && $2=="device" && $1 ~ /^emulator-/ {print $1}' } # Lists attached Wear OS watch serials (one per line). Pixel Watch 2 # reports device code "eos", Pixel Watch 1 is "rohan". list_watch_serials() { detect_adb "$ADB" devices -l | awk '$2=="device" && /device:(eos|rohan)/ {print $1}' } # Human-readable label for a serial, built from `adb devices -l` metadata. # Falls back to the bare serial if no extra info is available. serial_label() { local serial="$1" local line line=$("$ADB" devices -l | awk -v s="$serial" '$1==s {$1=""; sub(/^ +/,""); print}') if [[ -n "$line" ]]; then printf '%s — %s\n' "$serial" "$line" else printf '%s\n' "$serial" fi } # Output variable for the resolve_*_serial functions. Using a global # instead of stdout+$() lets `die` inside these functions abort the main # shell directly — $() would wrap everything in a subshell and swallow # the exit, which is how the earlier version silently leaked past errors. RESOLVED_SERIAL="" # Resolves the emulator serial the user wants to target. The script never # auto-picks a running emulator — even when only one is up, the user may # want to boot a different AVD instead. Bypass the menu by setting # ADB_SERIAL= or WEAR_AVD=. # - 0 emulators running → boot one (WEAR_AVD env or AVD menu); take its serial. # - ≥1 emulators running → always prompt; "Start a new AVD" is the last option. resolve_emulator_serial() { detect_adb local -a running mapfile -t running < <(list_emulator_serials) if [[ "${#running[@]}" -eq 0 ]]; then boot_emulator_or_die mapfile -t running < <(list_emulator_serials) if [[ "${#running[@]}" -eq 0 ]]; then die "Boot reported success but no emulator serial visible." fi RESOLVED_SERIAL="${running[0]}" return fi local -a labels=() local s for s in "${running[@]}"; do labels+=("$(serial_label "$s")") done local start_new_label="Start a new AVD" labels+=("$start_new_label") pick_from_list "Pick emulator:" "${labels[@]}" local choice="$PICKED" if [[ "$choice" == "$start_new_label" ]]; then local -a before=("${running[@]}") boot_emulator_or_die local -a after mapfile -t after < <(list_emulator_serials) local new="" local a p found for a in "${after[@]}"; do found=0 for p in "${before[@]}"; do [[ "$p" == "$a" ]] && { found=1; break; } done if [[ "$found" -eq 0 ]]; then new="$a" break fi done [[ -z "$new" ]] && die "Could not identify newly booted emulator serial." RESOLVED_SERIAL="$new" return fi # Chosen label has the form "emulator-5554 — "; strip to serial. RESOLVED_SERIAL="${choice%% *}" } # Resolves the watch serial to target. Prefers the cached pairing when # it's actually attached, falls back to a menu on ambiguity. resolve_watch_serial() { detect_adb local -a attached mapfile -t attached < <(list_watch_serials) local cached cached="$(read_cached_watch_serial)" if [[ -n "$cached" ]]; then local s for s in "${attached[@]}"; do if [[ "$s" == "$cached" ]]; then RESOLVED_SERIAL="$cached" return fi done fi if [[ "${#attached[@]}" -eq 0 ]]; then if [[ -n "$cached" ]]; then die "Cached watch serial '$cached' is not attached. Try './run.sh connect $cached' or './run.sh connect ' for a fresh pairing." fi die "No watch attached. Run './run.sh connect ' first." fi if [[ "${#attached[@]}" -eq 1 ]]; then RESOLVED_SERIAL="${attached[0]}" return fi local -a labels=() local s for s in "${attached[@]}"; do labels+=("$(serial_label "$s")") done pick_from_list "Multiple watches attached — pick one:" "${labels[@]}" RESOLVED_SERIAL="${PICKED%% *}" } apply_target() { case "$DOCTATE_TARGET" in watch) if [[ -z "$ADB_SERIAL" ]]; then resolve_watch_serial ADB_SERIAL="$RESOLVED_SERIAL" fi local server="${DOCTATE_DEV_SERVER:-$DEFAULT_DEV_SERVER}" GRADLE_PROPS+=("-Pdoctate.serverUrl=$server") info "Target: watch (serial=$ADB_SERIAL, server=$server)" ;; emulator) if [[ -z "$ADB_SERIAL" ]]; then resolve_emulator_serial ADB_SERIAL="$RESOLVED_SERIAL" fi info "Target: emulator (serial=$ADB_SERIAL, server defaults to 10.0.2.2:3000)" ;; "") die "No target selected. Use './run.sh watch …' or './run.sh emulator …' (or set DOCTATE_TARGET)." ;; *) die "Unknown target: '$DOCTATE_TARGET' (expected: watch | emulator)" ;; esac if [[ -n "${DOCTATE_API_KEY:-}" ]]; then GRADLE_PROPS+=("-Pdoctate.apiKey=$DOCTATE_API_KEY") fi if [[ -n "$ADB_SERIAL" ]]; then GRADLE_PROPS+=("-Pandroid.injected.device.serial=$ADB_SERIAL") # AGP's `android.injected.device.serial` silently falls back to "all # attached devices" when the serial doesn't match exactly (notably # with mDNS-TLS serials like adb--._adb-tls-connect._tcp). # ANDROID_SERIAL works one level deeper on every spawned adb binary, # so the filter holds even when AGP's own matcher doesn't. export ANDROID_SERIAL="$ADB_SERIAL" fi } ensure_device() { detect_adb # apply_target must have resolved a serial before any device-bound command runs. if [[ -z "$ADB_SERIAL" ]]; then die "Internal error: ADB_SERIAL not set. This is a bug in run.sh dispatch." fi if ! "$ADB" devices | awk 'NR>1 && $2=="device" {print $1}' | grep -qx "$ADB_SERIAL"; then die "Pinned device '$ADB_SERIAL' is no longer attached. Check './run.sh devices'." fi } boot_emulator_or_die() { local emulator_bin="$HOME/Android/Sdk/emulator/emulator" if [[ ! -x "$emulator_bin" ]]; then die "No device attached and emulator not found at $emulator_bin." fi local -a avds mapfile -t avds < <("$emulator_bin" -list-avds 2>/dev/null) if [[ "${#avds[@]}" -eq 0 ]]; then die "No device attached and no AVDs configured. Create one in Android Studio." fi local chosen="" # Env-var override: skip the menu (CI / scripted runs). if [[ -n "${WEAR_AVD:-}" ]]; then local a for a in "${avds[@]}"; do if [[ "$a" == "$WEAR_AVD" ]]; then chosen="$WEAR_AVD" break fi done if [[ -z "$chosen" ]]; then die "WEAR_AVD='$WEAR_AVD' not in available AVDs: ${avds[*]}" fi elif [[ -t 0 ]]; then info "No device attached. Available AVDs:" local i for i in "${!avds[@]}"; do printf " %d) %s\n" "$((i+1))" "${avds[$i]}" done printf " 0) abort\n" local choice read -r -p "Start which? " choice if [[ ! "$choice" =~ ^[0-9]+$ ]]; then die "Invalid choice: '$choice' (expected a number)." fi if [[ "$choice" -eq 0 ]]; then die "Aborted by user." fi if [[ "$choice" -lt 1 || "$choice" -gt "${#avds[@]}" ]]; then die "Out of range: $choice (have ${#avds[@]} AVDs)." fi chosen="${avds[$((choice-1))]}" else die "No device attached. Set WEAR_AVD= or run interactively. Available: ${avds[*]}" fi start_emulator_and_wait "$emulator_bin" "$chosen" } start_emulator_and_wait() { local emulator_bin="$1" local avd="$2" info "Starting AVD '$avd' (background)..." nohup "$emulator_bin" -avd "$avd" >/dev/null 2>&1 & disown info "Waiting for ADB to see the device..." adb_cmd wait-for-device info "Waiting for boot to complete (up to 180s)..." local boot="" local elapsed=0 local timeout=180 until [[ "$boot" == "1" ]]; do if [[ "$elapsed" -ge "$timeout" ]]; then die "Boot timeout after ${timeout}s (AVD '$avd')." fi sleep 2 elapsed=$((elapsed + 2)) boot=$(adb_cmd shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true) done ok "AVD '$avd' is up (boot took ${elapsed}s)." } # --- Subcommands ----------------------------------------------------------- cmd_build() { detect_java info "Building debug APK..." (cd "$SCRIPT_DIR" && ./gradlew "${GRADLE_PROPS[@]}" :app:assembleDebug) ok "Built." } cmd_install() { detect_java ensure_device info "Installing on device..." (cd "$SCRIPT_DIR" && ./gradlew "${GRADLE_PROPS[@]}" :app:installDebug) ok "Installed." } cmd_start() { ensure_device info "Starting $PACKAGE/$ACTIVITY..." adb_cmd shell am start -n "$PACKAGE/$ACTIVITY" >/dev/null ok "Started." } cmd_stop() { ensure_device info "Stopping $PACKAGE..." adb_cmd shell am force-stop "$PACKAGE" ok "Stopped." } cmd_logcat() { ensure_device local pid pid=$(adb_cmd shell pidof "$PACKAGE" | tr -d '\r' | awk '{print $1}') if [[ -z "$pid" ]]; then die "App $PACKAGE is not running. Use './run.sh start' first." fi info "Logcat for $PACKAGE (PID $pid). Ctrl-C to exit." exec "$ADB" ${ADB_SERIAL:+-s "$ADB_SERIAL"} logcat --pid="$pid" } cmd_shot() { ensure_device local out="${1:-/tmp/wear_screen.png}" info "Screenshot -> $out" adb_cmd exec-out screencap -p > "$out" ok "Saved ($(wc -c < "$out") bytes)." } cmd_devices() { detect_adb info "Attached devices:" "$ADB" devices -l | sed '1d;/^$/d' | sed 's/^/ /' if [[ -n "$ADB_SERIAL" ]]; then info "Pinned by current target: $ADB_SERIAL" fi local cached cached="$(read_cached_watch_serial)" if [[ -n "$cached" ]]; then info "Cached watch serial (.watch_serial): $cached" fi } cmd_connect() { detect_adb local target="${1:-}" if [[ -z "$target" ]]; then target="$(read_cached_watch_serial)" if [[ -z "$target" ]]; then die "Usage: ./run.sh connect (no cached serial in .watch_serial yet)." fi info "Reusing cached watch serial: $target" fi info "Connecting to $target..." "$ADB" connect "$target" # adb connect prints success or failure; verify it's actually 'device'. if "$ADB" devices | awk 'NR>1 {print $1, $2}' | grep -qx "$target device"; then printf '%s\n' "$target" > "$WATCH_SERIAL_FILE" ok "Connected and cached as watch serial." else die "Connect did not yield a 'device' state for $target. Check WiFi-Debug pairing." fi } cmd_clean() { detect_java info "Cleaning..." (cd "$SCRIPT_DIR" && ./gradlew clean) ok "Clean." } cmd_test() { detect_java info "Running JVM unit tests..." (cd "$SCRIPT_DIR" && ./gradlew :app:testDebugUnitTest) ok "Unit tests passed." if [[ "${SKIP_INSTRUMENTED:-0}" == "1" ]]; then info "SKIP_INSTRUMENTED=1 set; skipping connectedAndroidTest." return fi ensure_device info "Running instrumented tests..." (cd "$SCRIPT_DIR" && ./gradlew :app:connectedDebugAndroidTest) ok "Instrumented tests passed." } cmd_help() { # Print the leading comment block (lines 2..first non-comment line). awk 'NR==1 { next } /^[^[:space:]#]/ { exit } { print }' "$0" \ | sed 's/^# \{0,1\}//' } # --- Dispatch -------------------------------------------------------------- # Required target prefix (watch | emulator) before the subcommand. The legacy # "(none)" fallback is gone: every device-bound subcommand must know which # device it's talking to, otherwise we'd quietly pick "the first" — which is # exactly what the user asked to stop doing. if [[ "${1:-}" == "watch" || "${1:-}" == "emulator" ]]; then DOCTATE_TARGET="$1" shift fi # Subcommands that are device- and target-agnostic bypass apply_target so # they don't trigger a resolver menu as a side-effect. Ordering matters: # this short-circuit must run before apply_target. case "${1:-}" in help|-h|--help) cmd_help; exit 0 ;; devices) cmd_devices; exit 0 ;; connect) shift; cmd_connect "$@"; exit 0 ;; clean) cmd_clean; exit 0 ;; esac apply_target case "${1:-}" in "") cmd_install; cmd_start ;; build) cmd_build ;; install) cmd_install ;; start) cmd_start ;; stop) cmd_stop ;; logcat) cmd_logcat ;; shot) shift; cmd_shot "$@" ;; test) cmd_test ;; *) die "Unknown command: $1 (try './run.sh help')" ;; esac