#!/usr/bin/env bash # Emulator + on-device debug helpers for Pad (machine-local workstation). # # Usage: # scripts/emu.sh up # start headless AVD, wait for boot # scripts/emu.sh down # stop the emulator (clean; saves snapshot) # scripts/emu.sh status # adb / boot / top-activity state # scripts/emu.sh app start|stop|restart # scripts/emu.sh shot [FILE] # screenshot (default /tmp/pad_shot_.png) # scripts/emu.sh log [LINES] # last N lines of app logcat (default 20) # scripts/emu.sh tap X Y # tap at SCREEN px (1080x2400 space). # # Tap-what-you-see rule: read (x,y) off # # the PNG you are viewing and multiply # # both by 1.2 (vision displays # # screenshots at 900x2000). Note: the # # first tap right after launch/open is # # sometimes swallowed — re-tap. # scripts/emu.sh type TEXT # type into the focused field (spaces ok) # scripts/emu.sh cmd # one-shot editor debug command # scripts/emu.sh perf on|off # enable/disable the in-app profiler # scripts/emu.sh perf pull [FILE] # pull logic_frames.csv (default ./logic_frames.csv) # scripts/emu.sh push FILE # push FILE -> /storage/emulated/0/Notes/ # scripts/emu.sh pull NAME # pull Notes/NAME -> ./ # # Machine-local facts encoded here (see doc/README.md for context): # - AVD pad_avd (Pixel 6 profile, API 35, x86_64); headless + swiftshader. # Boot is fast (snapshot restore ~10 s, cold boot ~20-30 s warm). # - App pad.pad / org.gioui.GioActivity. # - Profiler: enable marker + CSV live under /storage/emulated/0/PadPerf/; # the CSV is truncated on app relaunch, so pull it before restarting the # app if you are accumulating data. # - One-shot editor debug commands are read from /storage/emulated/0/PadPerf/cmd # (scroll: top | bottom | "frac 0.5" | "dp 1234"). # - Test files go under /storage/emulated/0/Notes/. # - The emulator OOMs above ~2.5 GB RSS on this VM; kill it if it wedges. # # Snapshots: the AVD auto-saves a "default_boot" instant-boot snapshot on # clean shutdown. A corrupted snapshot makes the emulator segfault during # restore (device never comes online). `up` detects that (process death or # no adb device within PAD_ONLINE_TIMEOUT seconds) and falls back to a cold # boot with -no-snapshot-load; the next clean `down` re-saves a healthy # snapshot, so this self-heals. Always stop with `down` (clean kill saves # the snapshot) — SIGKILLing the emulator corrupts the snapshot. # # Env overrides: PAD_AVD, PAD_ONLINE_TIMEOUT (default 60 s), # PAD_BOOT_TIMEOUT (default 300 s), EMU_LOG path via ANDROID_HOME-adjacent. set -euo pipefail AVD=${PAD_AVD:-pad_avd} PKG=pad.pad ACTIVITY=org.gioui.GioActivity NOTES=/storage/emulated/0/Notes PERFDIR=/storage/emulated/0/PadPerf EMU_LOG=${PAD_EMU_LOG:-/tmp/emulator.log} ONLINE_TIMEOUT=${PAD_ONLINE_TIMEOUT:-60} BOOT_TIMEOUT=${PAD_BOOT_TIMEOUT:-300} export ANDROID_HOME=${ANDROID_HOME:-$HOME/android-sdk} export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator die() { echo "ERROR: $*" >&2; exit 1; } adb_state() { adb get-state 2>/dev/null || echo offline; } booted() { [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ] } EMU_PID="" # Find the actual qemu process for this AVD: comm is qemu-system-* and the # command line contains " -avd $AVD". The comm check excludes the emulator's # crashpad/netsimd children (their command lines carry the AVD name too) and # any unrelated process that merely mentions the AVD. Prints the pid. # Returns 0 if found, 1 otherwise. find_emu_qemu() { local p for p in $(pgrep -f " -avd $AVD" 2>/dev/null); do case "$(ps -o comm= -p "$p" 2>/dev/null)" in qemu-system-*) echo "$p"; return 0 ;; esac done return 1 } start_emulator() { nohup emulator -avd "$AVD" -no-window -no-audio -no-boot-anim \ -gpu swiftshader_indirect "$@" >"$EMU_LOG" 2>&1 & EMU_PID=$! } # Wait until the adb device is online. # Returns: 0 online, 1 emulator process died, 2 timeout. wait_device_online() { local deadline=${1:-$ONLINE_TIMEOUT} i=0 while [ "$i" -lt "$deadline" ]; do [ "$(adb_state)" = "device" ] && return 0 # Give the launcher time to exec into qemu before declaring death. if [ "$i" -ge 10 ] && ! find_emu_qemu >/dev/null; then return 1 fi sleep 3 i=$((i + 3)) done return 2 } # Wait for boot_completed on an already-online device. # Returns: 0 booted, 1 not booted (device lost or timeout). wait_boot_completed() { local deadline=${1:-$BOOT_TIMEOUT} i=0 while [ "$i" -lt "$deadline" ]; do booted && return 0 [ "$(adb_state)" = "device" ] || return 1 sleep 5 i=$((i + 5)) done return 1 } cmd_up() { if [ "$(adb_state)" = "device" ] && booted; then echo "emulator already up and booted" return 0 fi if [ "$(adb_state)" = "device" ]; then echo "emulator present; waiting for boot..." wait_boot_completed "$BOOT_TIMEOUT" || die "boot failed; see $EMU_LOG" echo "booted" return 0 fi echo "starting AVD $AVD (headless)..." # Clear any orphaned qemu left by a previously force-killed run: it would # hold the AVD lock and make the new launch fail silently. local qp if qp=$(find_emu_qemu); then echo "WARN: orphaned emulator (pid $qp) still running; killing it first" kill -9 "$qp" 2>/dev/null || true sleep 2 fi start_emulator local rc=0 wait_device_online "$ONLINE_TIMEOUT" || rc=$? if [ "$rc" -ne 0 ]; then if [ "$rc" -eq 1 ]; then echo "WARN: emulator process died during startup (corrupt snapshot?) — $EMU_LOG" else echo "WARN: device never came online within ${ONLINE_TIMEOUT}s — $EMU_LOG" fi echo "retrying with -no-snapshot-load (cold boot; next 'down' re-saves a good snapshot)..." # Kill the old instance (launcher and/or qemu). SIGKILL: the snapshot # is already broken, there is nothing to save, and a half-written # snapshot is harmless (next clean 'down' re-saves). qp=$(find_emu_qemu || true) if [ -n "$qp" ]; then kill -9 "$qp" 2>/dev/null || true; fi kill -9 "$EMU_PID" 2>/dev/null || true # Wait until it is truly gone: the old device and AVD lock must be # released before the replacement starts, or the new waits can latch # onto the dying instance and report false success. local j=0 while [ "$j" -lt 15 ]; do find_emu_qemu >/dev/null 2>&1 || break sleep 1 j=$((j + 1)) done start_emulator -no-snapshot-load # Note: full BOOT_TIMEOUT here — a cold boot legitimately takes far # longer than the ONLINE_TIMEOUT used for the first (snapshot) attempt. wait_device_online "$BOOT_TIMEOUT" || { tail -n 5 "$EMU_LOG" >&2; die "device never came online (cold boot); see $EMU_LOG"; } fi wait_boot_completed "$BOOT_TIMEOUT" || die "boot failed; see $EMU_LOG" echo "booted" } cmd_down() { adb emu kill 2>/dev/null || die "no emulator running" echo "stopped" } cmd_status() { echo "adb: $(adb_state)" if [ "$(adb_state)" = "device" ]; then echo "boot_completed: $(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" local top top=$(adb shell dumpsys activity activities 2>/dev/null | grep -m1 "topResumedActivity" || true) echo "top: ${top:-none}" fi } cmd_app() { case "${1:-}" in start) adb shell am start -n "$PKG/$ACTIVITY" >/dev/null; echo "started" ;; stop) adb shell am force-stop "$PKG"; echo "stopped" ;; restart) adb shell am force-stop "$PKG" sleep 1 adb shell am start -n "$PKG/$ACTIVITY" >/dev/null echo "restarted" ;; *) die "app: start|stop|restart" ;; esac } cmd_shot() { local out="${1:-/tmp/pad_shot_$(date +%Y%m%d_%H%M%S).png}" adb exec-out screencap -p >"$out" echo "$out" } cmd_log() { adb logcat -d -s "$PKG" | tail -n "${1:-20}" } cmd_tap() { [ $# -eq 2 ] || die "tap X Y" adb shell input tap "$1" "$2" } cmd_type() { [ $# -ge 1 ] || die "type TEXT" # input text takes one arg; encode spaces as %s. local text=${*// /%s} adb shell input text "$text" } cmd_cmd() { # Commands are constrained (top|bottom|frac F|dp N) — no shell metachars. [ $# -ge 1 ] || die "cmd " adb shell "mkdir -p $PERFDIR && printf '%s\n' \"$*\" > $PERFDIR/cmd" echo "sent editor debug command: $*" } cmd_perf() { case "${1:-}" in on) adb shell "mkdir -p $PERFDIR && touch $PERFDIR/enable"; echo "profiler on" ;; off) adb shell "rm -f $PERFDIR/enable"; echo "profiler off" ;; pull) local out="${2:-./logic_frames.csv}" adb pull "$PERFDIR/logic_frames.csv" "$out" ;; *) die "perf on|off|pull [FILE]" ;; esac } cmd_push() { [ $# -ge 1 ] || die "push FILE" adb push "$1" "$NOTES/" } cmd_pull() { [ $# -ge 1 ] || die "pull NAME" adb pull "$NOTES/$1" "./" } [ $# -ge 1 ] || die "no subcommand (see header)" main="${1}"; shift case "$main" in up) cmd_up "$@" ;; down) cmd_down "$@" ;; status) cmd_status "$@" ;; app) cmd_app "$@" ;; shot) cmd_shot "$@" ;; log) cmd_log "$@" ;; tap) cmd_tap "$@" ;; type) cmd_type "$@" ;; cmd) cmd_cmd "$@" ;; perf) cmd_perf "$@" ;; push) cmd_push "$@" ;; pull) cmd_pull "$@" ;; *) die "unknown subcommand: $main" ;; esac