feat(dev): an Android failure looks exactly like a success

The APK installs and launches. It also dies six milliseconds later, and
finding that out cost a cycle for three reasons that have nothing to do
with the bug itself:

**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go
to /dev/null, so every slog line -- including the one naming the error
the app is about to exit on -- is discarded. `setprop
log.redirect-stdio true` does not help: that redirects the Java
runtime's System.out, and our code is a c-shared native library.

**os.Exit leaves no evidence.** No panic, no AndroidRuntime stack,
nothing in /data/tombstones, nothing in `logcat -b crash` or dropbox.
All three places anyone would look are empty, and the one signal that
is present -- "Zygote: exited due to signal 9" -- reads as "the system
killed it" and sends you after the low-memory killer.

**ActivityManager restarts it faster than you can observe.** pidof
always answers and `am start` always reports Status: ok, so a
crash-looping app looks alive. "Did it start" is the wrong question;
`make android-smoke` asks whether it is the *same pid* N seconds later,
and prints the filtered logcat plus how to read it when it is not.

The tell, once known: "I/WailsBridge: Wails bridge initialized"
followed immediately by a new pid doing the same thing.

scripts/android-emulator.sh follows dev-headless.sh's shape --
background start, saved-PID stop, filtered log tail, never pkill -f.
Two scaffold tasks are deliberately not wrapped: `android:logs` greps
logcat for (Wails|yellowjacket), which catches the WailsBridge tag but
misses the app's own process tag (app.yellowjacket is lowercase) and
misses ActivityManager's "has died" line, which is the one that says it
crashed; and `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be sequenced.

One environment note that is not obvious on Arch: Gradle needs a
platform and /opt/android-sdk has none, so ANDROID_SDK defaults to
~/Android/Sdk while ANDROID_NDK points at /opt/android-ndk. Two SDKs,
one for each half of the build.
This commit is contained in:
2026-08-16 15:31:18 -04:00
parent 6fbb62730d
commit 68468e5378
4 changed files with 451 additions and 0 deletions
+7
View File
@@ -151,6 +151,7 @@ only climb when it cannot.
| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive |
| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup |
| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | |
| Anything that has to survive on a phone | `make android-smoke` against a booted emulator | ~1 min + setup |
Two targets are once-per-clone prerequisites that are **not**
dependencies of the targets needing them, so on a fresh checkout each
@@ -426,3 +427,9 @@ fails the build otherwise, including in files no lint pass compiles.
and what breaks in it.
- [schema-change.md](references/schema-change.md) — the two-file
schema/migration checklist.
- [android-tier.md](references/android-tier.md) — the emulator tier,
and the three reasons a failure there looks like a success. **Read
its first section before running anything on Android**: Go's stdout
does not reach logcat, `os.Exit` leaves no panic and no tombstone,
and ActivityManager restarts a dying app fast enough that `pidof`
always answers.
@@ -0,0 +1,169 @@
# The Android tier
A sixth tier, and the only one where **the app failing looks exactly
like the app working**. Read the first section before you run anything;
it is the difference between a diagnosis and an afternoon.
This tier answers "does the phone build run", nothing else. It is not a
spec tier, it does not run in CI, and the app is not a usable Android
player yet (plan 015 says why, at length).
## Three facts that make failure invisible
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
`/dev/null`. Every `slog` line the app writes is discarded — including
the one naming the error it is about to exit on. `setprop
log.redirect-stdio true` does not help: it redirects the *Java*
runtime's `System.out`, and the Go code is a c-shared native library.
**`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:
`ActivityManager: Process com.wails.app has died`, `Zygote: exited due
to signal 9`, and **no** panic, **no** `AndroidRuntime` stack, **no**
tombstone under `/data/tombstones` and nothing in `logcat -b crash` or
dropbox. All three of the places you would look are empty, and the one
signal that is present — SIGKILL — reads as "the system killed it",
which is the wrong hypothesis.
**ActivityManager restarts it, so a dead app looks alive.** A
crash-looping app is respawned several times a second, so `pidof` always
answers and `am start` always reports `Status: ok`. "Did it start" is
the wrong question. `make android-smoke` asks the right one — is it the
*same pid* a few seconds later.
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
followed immediately by a new pid doing the same thing. That means the
native library loaded, the JNI bridge came up, Go's `main()` ran, and
`main()` left. Work backwards through its `os.Exit(1)` paths.
## What to run
One-time, ~3.5 GB:
```bash
make android-setup # SDK pieces + the yj-test AVD, idempotent
```
Then:
```bash
make android # fat APK (arm64 + x86_64) -> bin/yellowjacket.apk
make android-emulator # boot headless in the background, wait for boot
make android-install # adb install -r
make android-smoke # launch, then assert the same pid survives 10s
make android-logs # filtered logcat, follow
make android-emulator-stop # console kill, then the saved PID
```
`make android-smoke SECONDS=30` for a longer window. On failure it
prints the last 40 app-relevant logcat lines and how to read them.
Never `pkill -f emulator` — the pattern matches the invoking shell's own
command line and kills it, silently dropping the rest of your compound
command. The emulator is addressed by its saved pid in
`.dev/emulator.pid`, same discipline as `make dev-stop`.
## Things that cost a cycle
- **`ANDROID_HOME` must carry a platform, and Arch's does not.**
`/opt/android-sdk` (the `android-sdk` package) has an NDK and
build-tools but `platforms/` is *empty*, so Gradle fails with a
compileSdk error that reads like a version mismatch. The Makefile
defaults `ANDROID_SDK` to `~/Android/Sdk` (user-owned, writable,
where sdkmanager puts things) and `ANDROID_NDK` to `/opt/android-ndk`
separately, because the Go half wants the NDK and the Gradle half
wants the platform and they are in different places.
- **The NDK is pinned to r26d** (`26.3.11579264`, Arch's
`android-ndk-26`). Newer NDKs have broken the Wails Android build
before. CI pins the same one.
- **Without KVM the emulator still works and is unusably slow** — a 30 s
boot becomes tens of minutes, which reads as a hung target rather than
a slow one. `make android-setup` checks and warns.
- **`-no-snapshot` is deliberate.** A snapshot-resumed emulator carries
the previous run's app state, and a smoke result that depends on what
the last run left behind is not a result.
- **The logcat filter is not optional.** The emulator emits thousands of
lines a second, nearly all WindowManager transitions; an unfiltered
`adb logcat` buries the six lines that matter. `make android-logs`
filters to `WailsBridge`, the app's own tag, `GoLog`, `AndroidRuntime`,
`DEBUG` and `libc:F`.
- **`run-as` does not work on a release-signed APK** (`package not
debuggable`), so you cannot read the app's data directory or its
environment that way. Ask the device instead, or build a debug variant.
- **The `google_apis` system image, not `default`.** This app is a
WebView app; `google_apis` ships the Chrome-based WebView that
actually renders it.
## The current state of the build
`make android-smoke` **fails today, and the cause is known.**
`backend/system`'s `buildUserDirPath` switches on `runtime.GOOS` with
cases for darwin, linux and windows, and a `default:` that returns
`errUnsupportedOS`. `runtime.GOOS` is `"android"`, so it takes the
default, `NewYellowJacketApp` fails, and `main()` calls `os.Exit(1)` —
about 6 ms after the bridge initialises, which is exactly the signature
described above.
**The fix is a documented Wails API and needs no build tags.**
`application.Mobile.StoragePath()` returns the app's private files
directory and returns `""` on desktop (`mobile_stub.go`), and
`resolveUserDirPath` already lets `YJ_HOME` override the path on every
OS — so setting that override from `StoragePath()` early in `main()`,
when it is non-empty, is the whole change. Do *not* import
`pkg/application` into `backend/system`: that package is deliberately
Wails-free, which is what the `indexbuild` tag split is protecting.
It is the *first* thing that stops it, not the only one. MPRIS is
compiled in (`android` implies the `linux` build tag, so
`mpris_linux.go` is in the build and will look for a session bus that
does not exist), and the desktop shell is still a desktop shell. Fixing
one and re-running the smoke is how you find the next.
**And one that no amount of porting will fix:** open-*directory*
dialogs return an error on Android — the Storage Access Framework gives
tree URIs, not filesystem paths — as do save-file dialogs. This app's
first run is "choose your music folder" and its library model is
filesystem paths, so that is a design question, not a port.
## The scaffold's own tasks
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
they are the right thing to reach for when you want something one-off:
```
wails3 task android:run # debug build + emulator install + launch
wails3 task android:run:device # same, first connected physical device
wails3 task android:deploy-device # production APK to a device
wails3 task android:bundle:fat # AAB, for a Play Store upload
wails3 task android:studio # open build/android/ in Android Studio
wails3 task android:device:list
wails3 task android:logs:all
wails3 task android:clean
```
Two are deliberately **not** wrapped. `android:logs` greps logcat for
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
does not match it) and misses `ActivityManager`'s "has died" line, which
is the one that tells you it crashed; `make android-logs` filters by tag
instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
returns, with no pidfile and no boot wait, so it cannot be stopped or
sequenced.
## The identity is declared twice
`applicationId` in `build/android/app/build.gradle` is what Gradle
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
adb-driven task uninstalls, launches and filters. **Nothing enforces
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
that file (verified with `--dry`), and even when set it feeds only the
adb commands, never Gradle. Change both or the official `run`/`deploy`
tasks address a package that is not installed.
Related, and it will bite once: the launcher activity is
`com.wails.app.MainActivity` and the applicationId is
`app.yellowjacket`. `am start -n app.yellowjacket/.MainActivity`
resolves the leading dot against the *applicationId* and fails with a
class-not-found that reads like a broken build. Always the
fully-qualified form.
+40
View File
@@ -38,6 +38,46 @@ dev-stop: ## Stop the headless app (SIGTERM, so shutdown hooks run)
dev-logs: ## Tail the headless app log
@tail -f .dev/app.log
# ---------------------------------------------------------------- #
# The Android tier. See .pi/skills/yellowjacket-dev/references/ #
# android-tier.md for which of these to reach for and why a failure #
# here looks like nothing at all. #
# ---------------------------------------------------------------- #
# The NDK is pinned: r26d is what the pipeline is built and checked
# against, and newer NDKs have broken Wails' Android build before.
# ANDROID_HOME must carry a *platform*, which Arch's /opt/android-sdk
# does not — hence the separate default.
ANDROID_SDK ?= $(HOME)/Android/Sdk
ANDROID_NDK ?= /opt/android-ndk
ANDROID_ENV := ANDROID_HOME=$(ANDROID_SDK) ANDROID_SDK_ROOT=$(ANDROID_SDK) ANDROID_NDK_HOME=$(ANDROID_NDK)
android: build-frontend ## Build the fat APK (arm64 + x86_64) into bin/
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package:fat
android-setup: ## Install the SDK pieces and create the AVD (once, ~3.5GB)
@$(ANDROID_ENV) ./scripts/android-emulator.sh setup
android-emulator: ## Boot the emulator headless in the background and wait for it
@$(ANDROID_ENV) ./scripts/android-emulator.sh start
android-emulator-stop: ## Shut the emulator down (console kill, then saved PID)
@$(ANDROID_ENV) ./scripts/android-emulator.sh stop
android-install: ## Install bin/yellowjacket.apk onto the running emulator
@$(ANDROID_ENV) ./scripts/android-emulator.sh install
android-launch: ## Force-stop, clear logcat, and start the app
@$(ANDROID_ENV) ./scripts/android-emulator.sh launch
android-logs: ## Tail logcat, filtered to the app's own tags
@$(ANDROID_ENV) ./scripts/android-emulator.sh logs
# "Did it start" is the wrong question — a crash-looping app starts
# several times a second. This asserts the *same pid* is still there.
android-smoke: ## Launch and assert the app is still alive (SECONDS=<n>)
@$(ANDROID_ENV) ./scripts/android-emulator.sh smoke $(if $(SECONDS),$(SECONDS),10)
# Seeds are produced by *running the app* — driving the real AddLibrary
# binding and waiting for the real scan — never by hand-writing a
# config.toml and DB rows. A hand-built seed is a second description
+235
View File
@@ -0,0 +1,235 @@
#!/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}"
# **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'"
}
# 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"
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"
"$ADB" get-state >/dev/null 2>&1 || die "no device — run 'make android-emulator' first"
"$ADB" install -r bin/yellowjacket.apk
}
cmd_launch() {
need_sdk
"$ADB" shell am force-stop "$PKG"
"$ADB" logcat -c
"$ADB" shell am start -n "$PKG/$ACTIVITY" >/dev/null
}
cmd_logs() {
need_sdk
# 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'
}
# 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
local first
first=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}')
sleep "$wait_s"
local second
second=$("$ADB" shell pidof "$PKG" 2>/dev/null | tr -d '\r' | awk '{print $1}')
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 ;;
smoke) cmd_smoke "${2:-10}" ;;
*)
echo "usage: $0 {setup|start|stop|install|launch|logs|smoke [seconds]}" >&2
exit 2
;;
esac