Files
yellowjacket/scripts/android-emulator.sh
T
logan 0bfa2136be
Build & publish Arch package / arch-package (push) Successful in 2m29s
CI / check (push) Successful in 2m26s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Successful in 5m53s
feat(dev): ask the phone instead of looking at it
The device tier could only take a screenshot and read what Go chose to
log, and a screenshot cannot tell a dropped CSS declaration from a
missing asset. This adds the third thing: the page's own answer, from
the engine that is really rendering it.

`make android-screenshot` grabs the screen, `make android-inspect`
forwards the WebView's devtools socket, and `make android-eval EXPR=...`
evaluates in the real page.

Four details are load-bearing. Only a `debuggable` build opens that
socket, so the debug build type takes `applicationIdSuffix ".dev"` and
installs *beside* the release app -- the two carry different signing
certificates, and Android's only remedy for a changed certificate is an
uninstall, which takes the user's library with it. Playwright cannot
drive a WebView (`connectOverCDP` calls `Browser.setDownloadBehavior`,
which it answers "Browser context management is not supported"), so the
eval is raw CDP over Node's built-in WebSocket. The socket name carries
the pid, so it is resolved per launch rather than written down. And
`exec-out`, not `shell`, for the screenshot: a pty translates LF and
corrupts the PNG.

What it immediately established is why it was worth having. The phone
renders in Chrome 113 at 424x439 CSS px -- two years behind every
browser the other tiers use, with no Popover API and no relaxed CSS
nesting -- so a spec passing at that viewport says nothing about the
device, and two conclusions drawn from version numbers alone were wrong.
Both are corrected in NOTES.md and the plan.
2026-08-17 05:13:21 -04:00

390 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# The Android tier: an emulator, an APK, and a way to find out why the
# app died.
#
# This is the phone equivalent of `dev-headless.sh`, and it is
# deliberately shaped like it — start in the background and return,
# stop by saved state, tail a log — because the operating pattern is
# the one this repo already has. What is different is *what a failure
# looks like*, and that is the whole reason this script exists rather
# than a paragraph telling you to run adb.
#
# **Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go
# to /dev/null, so every `slog` line the app writes — including the one
# naming the error it is about to exit on — is discarded. There is no
# flag for this: `setprop log.redirect-stdio true` redirects the *Java*
# runtime's System.out and does nothing for a c-shared Go library.
#
# **And `os.Exit` is a silent death.** `main()` ends several failure
# paths in `os.Exit(1)`; from Android's side that is a process that
# vanished, reported as "has died: fg TOP" and signal 9, with no panic,
# no `AndroidRuntime` stack and no tombstone — the three places anyone
# would look. ActivityManager then restarts it, so `pidof` answers with
# a pid and the app looks alive while crash-looping several times a
# second.
#
# `smoke` exists because of those two facts together: the honest test
# is not "did it start" but "is the same pid still there a few seconds
# later", and the useful output is the app's own logcat tags plus a
# named guess at which `os.Exit` it took.
set -euo pipefail
cd "$(dirname "$0")/.."
AVD="${YJ_AVD:-yj-test}"
SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}"
PKG="${YJ_ANDROID_PKG:-app.yellowjacket}"
# Where `make android-inspect` forwards the WebView's devtools socket.
CDP_PORT="${YJ_ANDROID_CDP_PORT:-9222}"
# **Not "$PKG/.MainActivity".** A leading-dot activity is resolved
# relative to the *applicationId*, and the scaffold's activity lives in
# the Java package `com.wails.app`, which is deliberately not the
# applicationId (see app/build.gradle). The short form silently
# resolves to app.yellowjacket.MainActivity, which does not exist, and
# `am start` fails with a class-not-found that reads like a broken
# build rather than a wrong name.
ACTIVITY="${YJ_ANDROID_ACTIVITY:-com.wails.app.MainActivity}"
IMAGE="${YJ_ANDROID_IMAGE:-system-images;android-35;google_apis;x86_64}"
DEVDIR=".dev"
PIDFILE="$DEVDIR/emulator.pid"
LOGFILE="$DEVDIR/emulator.log"
ADB="$SDK/platform-tools/adb"
EMULATOR="$SDK/emulator/emulator"
SDKMANAGER="$SDK/cmdline-tools/latest/bin/sdkmanager"
AVDMANAGER="$SDK/cmdline-tools/latest/bin/avdmanager"
die() { echo "android: $*" >&2; exit 1; }
need_sdk() {
[ -x "$ADB" ] || die "no adb at $ADB — set ANDROID_SDK_ROOT, or run 'make android-setup'"
[ -x "$EMULATOR" ] || die "no emulator at $EMULATOR — run 'make android-setup'"
}
# Address one device explicitly, because a bare `adb` addresses whatever
# is attached and there is very often something else attached: another
# project's emulator, or this one's own corpse left `offline` by a
# previous run. Both make every adb call here fail with "more than one
# device", which cmd_install then reports as "no device — run 'make
# android-emulator' first" *immediately after* that succeeded.
#
# The AVD name is the identity, not the serial: serials are assigned in
# boot order and change between runs. ANDROID_SERIAL is honoured if the
# caller set it, and is what every later `adb` in this script reads.
pick_device() {
[ -n "${ANDROID_SERIAL:-}" ] && return 0
online=$("$ADB" devices | awk '$2 == "device" { print $1 }')
[ -n "$online" ] || return 1
for serial in $online; do
name=$("$ADB" -s "$serial" shell getprop ro.boot.qemu.avd_name 2>/dev/null | tr -d '\r')
[ -n "$name" ] || name=$("$ADB" -s "$serial" shell getprop ro.kernel.qemu.avd_name 2>/dev/null | tr -d '\r')
if [ "$name" = "$AVD" ]; then
export ANDROID_SERIAL="$serial"
return 0
fi
done
# No AVD of ours, but exactly one device: a physical phone, which is
# the one target this tier actually wants (see android-tier.md).
if [ "$(printf '%s\n' "$online" | wc -l)" -eq 1 ]; then
export ANDROID_SERIAL="$online"
return 0
fi
echo "android: several devices and none is the '$AVD' AVD:" >&2
"$ADB" devices | sed '1d;/^$/d;s/^/ /' >&2
echo " set ANDROID_SERIAL to choose one" >&2
return 1
}
# The emulator is the only long-lived process here, and it is addressed
# by its saved pid. Never by name: `pkill -f emulator` matches this
# script's own command line and kills the shell running it, which is
# the same trap dev-stop.sh documents.
running() {
[ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null
}
cmd_setup() {
[ -x "$SDKMANAGER" ] || die "no sdkmanager at $SDKMANAGER; install the Android command line tools first"
# Each piece is installed only when missing. sdkmanager is itself
# idempotent but still spends minutes verifying, so the guards are
# what make this cheap to re-run.
for want in "platform-tools" "platforms;android-35" "build-tools;34.0.0" "$IMAGE"; do
dir="$SDK/$(printf '%s' "$want" | tr ';' '/')"
if [ -d "$dir" ]; then
echo " $want: present"
else
echo " $want: installing"
yes | "$SDKMANAGER" --install "$want" >/dev/null
fi
done
if "$EMULATOR" -list-avds 2>/dev/null | grep -qx "$AVD"; then
echo " avd $AVD: present"
else
echo " avd $AVD: creating"
echo no | "$AVDMANAGER" create avd -n "$AVD" -k "$IMAGE" -d pixel_6 --force >/dev/null
fi
# A dependency with a requirement, checked like one. Without KVM the
# emulator falls back to full software emulation and a boot that
# takes 30 s takes 20 minutes — which reads as a hung target.
if ! "$EMULATOR" -accel-check 2>&1 | grep -q "is installed and usable"; then
echo
echo " WARNING: KVM is not usable. The emulator will run under software"
echo " emulation and boot times go from ~30s to tens of minutes."
echo " Check /dev/kvm exists and that you are in the kvm group."
fi
}
cmd_start() {
need_sdk
mkdir -p "$DEVDIR"
if running; then
echo "emulator already running (pid $(cat "$PIDFILE"))"
else
"$EMULATOR" -list-avds 2>/dev/null | grep -qx "$AVD" ||
die "no AVD named '$AVD' — run 'make android-setup'"
# -no-window because there is no display and does not need one;
# -no-snapshot so a run starts from the same state every time,
# which is what makes a smoke result mean something.
nohup "$EMULATOR" -avd "$AVD" \
-no-window -no-boot-anim -no-snapshot \
-gpu swiftshader_indirect \
-netdelay none -netspeed full \
>"$LOGFILE" 2>&1 &
echo $! >"$PIDFILE"
echo "emulator starting (pid $(cat "$PIDFILE")), log: $LOGFILE"
fi
echo -n "waiting for boot"
"$ADB" wait-for-device >/dev/null 2>&1 || die "device never appeared; see $LOGFILE"
pick_device || die "the emulator booted but could not be addressed"
for _ in $(seq 1 150); do
if [ "$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = "1" ]; then
echo " ok"
"$ADB" shell getprop ro.build.version.release |
sed 's/^/ android /'
return 0
fi
echo -n .
sleep 2
done
echo
die "boot did not complete in 300s; see $LOGFILE"
}
cmd_stop() {
if running; then
pid=$(cat "$PIDFILE")
# The emulator's own console command shuts the guest down
# cleanly; the saved pid is the fallback and the guarantee.
"$ADB" emu kill >/dev/null 2>&1 || true
for _ in $(seq 1 15); do
kill -0 "$pid" 2>/dev/null || break
sleep 1
done
kill -0 "$pid" 2>/dev/null && kill "$pid" 2>/dev/null || true
echo "emulator stopped"
else
echo "emulator not running"
fi
rm -f "$PIDFILE"
}
cmd_install() {
need_sdk
[ -f bin/yellowjacket.apk ] || die "no bin/yellowjacket.apk — run 'make android' first"
pick_device || die "no device — run 'make android-emulator' first"
"$ADB" get-state >/dev/null 2>&1 || die "no device — run 'make android-emulator' first"
# The two ways this fails are both about identity rather than the
# build, and neither error says what to do about it.
#
# A *downgrade* is the versionCode rule working as designed: a bare
# `make android` produces versionCode 1, so it will not install over
# anything a versioned build left behind. A *signature* mismatch is
# the rule this whole pipeline exists for — a debug-signed local
# build cannot replace a release-signed one.
#
# Both are fixed by uninstalling, and on a throwaway emulator that
# costs nothing, so say so rather than making someone read the
# constant name.
out=$("$ADB" install -r bin/yellowjacket.apk 2>&1) || {
printf '%s\n' "$out"
case "$out" in
*INSTALL_FAILED_VERSION_DOWNGRADE*)
echo
echo "The installed copy has a higher versionCode than this build."
echo "A bare 'make android' builds versionCode 1; a versioned one"
echo "builds e.g. 10301. Either uninstall:"
echo " $ADB uninstall $PKG"
echo "or build with a version:"
echo " YJ_VERSION=1.3.1 YJ_VERSION_CODE=10301 make android"
;;
# The inner quotes are load-bearing: `do` is a reserved word, and
# an unquoted one in a case pattern is a syntax error that fails
# the parse of the *whole file* -- so every subcommand here died
# with "line 190: syntax error near unexpected token `do'", not
# just install.
*INSTALL_FAILED_UPDATE_INCOMPATIBLE* | *"signatures do not match"*)
echo
echo "The installed copy was signed with a different key. Android"
echo "never allows that as an update — which is exactly why CI"
echo "refuses to publish a debug-signed APK. Uninstall:"
echo " $ADB uninstall $PKG"
;;
esac
return 1
}
printf '%s\n' "$out"
}
cmd_launch() {
need_sdk
pick_device || die "no device — run 'make android-emulator' first"
"$ADB" shell am force-stop "$PKG"
"$ADB" logcat -c
"$ADB" shell am start -n "$PKG/$ACTIVITY" >/dev/null
}
cmd_logs() {
need_sdk
pick_device || die "no device — run 'make android-emulator' first"
# The app's own tags plus the two that report its death. Chasing a
# raw logcat here is hopeless: the emulator emits thousands of lines
# a second, almost all of them WindowManager transitions.
"$ADB" logcat -v time \
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S'
}
# Forward the WebView's devtools socket, so the page can be asked things.
#
# Only a `debuggable` build opens that socket, and a debug build carries
# `applicationIdSuffix ".dev"` precisely so it can be installed *beside*
# the release app: the two are signed by different certificates, and
# Android's remedy for a certificate change is an uninstall, which takes
# the user's library with it. So this looks for the sibling first and the
# release id second.
#
# The socket name carries the pid, which changes on every launch -- which
# is why this resolves it rather than documenting a number.
cmd_inspect() {
need_sdk
pick_device || die "no device -- plug a phone in (USB debugging on) or run 'make android-emulator'"
local pkg pid
pid=""
for pkg in "$PKG.dev" "$PKG"; do
pid=$("$ADB" shell pidof "$pkg" 2>/dev/null | tr -d '\r' | awk '{print $1}')
[ -n "$pid" ] && break
done
[ -n "$pid" ] || die "neither $PKG.dev nor $PKG is running; launch it first"
"$ADB" forward --remove-all >/dev/null 2>&1 || true
"$ADB" forward "tcp:$CDP_PORT" "localabstract:webview_devtools_remote_$pid" >/dev/null \
|| die "adb forward failed"
echo "android: $pkg (pid $pid) devtools on http://localhost:$CDP_PORT"
echo " make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'"
echo " (a release build has no devtools socket: build and install the debug one)"
}
# What the phone is actually showing.
#
# This tier exists because no other one can see the platform: system
# bars, the safe area, the keyboard, an OEM's permission dialog. All of
# those are things you have to *look* at, and two of the three faults
# found so far were found by reading a picture rather than an assertion
# (`android-tier.md`).
#
# `exec-out` and not `shell`: `adb shell` runs the output through a pty
# on some platforms, which translates LF and corrupts the PNG -- for
# which the symptom is an image viewer refusing a file that downloaded
# perfectly.
cmd_screenshot() {
need_sdk
pick_device || die "no device \u2014 plug a phone in (USB debugging on) or run 'make android-emulator'"
local out="${1:-}"
[ -n "$out" ] || out=".dev/android-$(date +%Y%m%d-%H%M%S).png"
mkdir -p "$(dirname "$out")"
"$ADB" exec-out screencap -p > "$out" || die "screencap failed"
[ -s "$out" ] || die "screencap produced nothing (is the screen locked?)"
echo "android: screenshot -> $out"
}
# Start the app and assert it is *still the same process* a few seconds
# later. "It started" is not the question — a crash-looping app starts
# continuously.
cmd_smoke() {
need_sdk
local wait_s="${1:-10}"
cmd_launch
sleep 3
# **`|| true` is load-bearing.** `pidof` exits 1 when it finds
# nothing, and under `set -e` a failing command substitution kills
# the script -- silently, before it can print why. That is invisible
# for as long as the app crash-*loops*, because there is always some
# pid; it appears the moment the app dies for good and ActivityManager
# stops respawning it, which is exactly the run you most want output
# from.
local first second
first=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}' || true)
sleep "$wait_s"
second=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}' || true)
if [ -n "$first" ] && [ "$first" = "$second" ]; then
echo "PASS: $PKG alive as pid $first after ${wait_s}s"
return 0
fi
echo "FAIL: $PKG is not stable (pid was '${first:-none}', now '${second:-none}')"
if [ -n "$first" ] && [ -n "$second" ]; then
echo " The pid changed: it is crash-looping, not running."
fi
echo
echo "--- last 40 app-relevant logcat lines ---"
"$ADB" logcat -d -v time \
WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F '*:S' 2>/dev/null |
tail -40
echo
echo "--- reading this ---"
echo "If the last line is 'Wails bridge initialized' and nothing follows,"
echo "the Go side reached main() and left it. There will be no panic and"
echo "no tombstone, because that is os.Exit, not a crash. Go's stdout"
echo "does not reach logcat, so the slog line naming the error is gone."
echo "Work backwards through main()'s os.Exit(1) paths instead."
return 1
}
case "${1:-}" in
setup) cmd_setup ;;
start) cmd_start ;;
stop) cmd_stop ;;
install) cmd_install ;;
launch) cmd_launch ;;
logs) cmd_logs ;;
screenshot) cmd_screenshot "${2:-}" ;;
inspect) cmd_inspect ;;
smoke) cmd_smoke "${2:-10}" ;;
*)
echo "usage: $0 {setup|start|stop|install|launch|logs|screenshot [path]|inspect|smoke [seconds]}" >&2
exit 2
;;
esac