From 68468e5378287868a92a9cdffdccb9664091d392 Mon Sep 17 00:00:00 2001 From: Logan Date: Sun, 16 Aug 2026 15:30:35 -0400 Subject: [PATCH] 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. --- .pi/skills/yellowjacket-dev/SKILL.md | 7 + .../references/android-tier.md | 169 +++++++++++++ Makefile | 40 +++ scripts/android-emulator.sh | 235 ++++++++++++++++++ 4 files changed, 451 insertions(+) create mode 100644 .pi/skills/yellowjacket-dev/references/android-tier.md create mode 100755 scripts/android-emulator.sh diff --git a/.pi/skills/yellowjacket-dev/SKILL.md b/.pi/skills/yellowjacket-dev/SKILL.md index 56a2b47..4aba9a8 100644 --- a/.pi/skills/yellowjacket-dev/SKILL.md +++ b/.pi/skills/yellowjacket-dev/SKILL.md @@ -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. diff --git a/.pi/skills/yellowjacket-dev/references/android-tier.md b/.pi/skills/yellowjacket-dev/references/android-tier.md new file mode 100644 index 0000000..6853517 --- /dev/null +++ b/.pi/skills/yellowjacket-dev/references/android-tier.md @@ -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. diff --git a/Makefile b/Makefile index c8409e5..5c2d9af 100644 --- a/Makefile +++ b/Makefile @@ -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=) + @$(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 diff --git a/scripts/android-emulator.sh b/scripts/android-emulator.sh new file mode 100755 index 0000000..e7d7673 --- /dev/null +++ b/scripts/android-emulator.sh @@ -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