Files
yellowjacket/.pi/skills/yellowjacket-dev/references/android-tier.md
T
logan 8a757c9bb4
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m34s
CI / e2e (pull_request) Successful in 8m9s
docs(android): record the lifecycle model and the device check
The lifecycle answer is load-bearing, so CLAUDE.md states it: an
activity is a view onto the process, and main() runs once per process.
The "restore the session or cold-start" question the issue asks for a
decision on is settled by playback rather than by preference -- the
audio lives in the Go process, so a cold start on every recreation
stops the music mid-song, which is the thing the foreground service
exists to prevent.

android-tier.md's build table said "arm64, real device -- unverified,
still" for five phases. It is verified now, on a Light Phone III
(Android 14, arm64-v8a, WebView Chrome 113 at 424x439), and what the
run found is a lifecycle section: how to force an activity recreation
on demand, the three-line logcat signature, why `has died: fg TOP` is
not a memory kill, and the second assertion that surviving does not
imply working.

It also carries the correction that "Don't keep activities" -- the
report's own suggested lever -- does not work on this device at all,
so nobody spends an afternoon on it. A configuration change the
manifest does not declare does, in one line.

And it stops recommending `wails3 task android:run:device`, which
uninstalls the released app and the user's library to install a build
with a different id (#159), in favour of the manual sequence.

NOTES.md carries the measurements, dated: 8 of 8 recreations fatal
before, 5 of 5 survived after, and the note that runs where no
recreation happened are inconclusive rather than passes -- a harness
that does not check for the second bridge init reports those as green
and reads as flakiness.

Refs #52, #159, #160
2026-08-20 13:04:18 -04:00

21 KiB

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:

make android-setup          # SDK pieces + the yj-test AVD, idempotent

Then:

make android                # arm64-v8a APK -> bin/yellowjacket.apk (~16 MB)
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.

adb is addressed by AVD name, not by whatever is plugged in. The script resolves ANDROID_SERIAL from ro.boot.qemu.avd_name before any device command, because a second emulator (another project's, or this one's own corpse left offline by a previous run) makes a bare adb fail with "more than one device" — which cmd_install reported as "no device — run 'make android-emulator' first" immediately after that had succeeded. Serials are assigned in boot order and change between runs, so the AVD name is the identity. Set ANDROID_SERIAL yourself and it is honoured; one device that is not ours (a phone) is taken as the target.

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

The app starts. The x86_64 emulator cannot run it, and that is not a bug in the app.

modernc.org/libc — which modernc.org/sqlite, and therefore the whole database layer, sits on — issues a raw lstat syscall on linux/amd64 (libc_linux_amd64.go's Xlstat64 calls unix.Syscall(unix.SYS_LSTAT, …)). Android's seccomp policy forbids syscall 6 on x86_64, because bionic never issues it, so the process takes SIGSYS the first time anything touches the database:

F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6

arm64 is unaffected, and structurally so. There is no lstat syscall on arm64 at all, so ccgo_linux_arm64.go's Xlstat is Xfstatat(…, AT_SYMLINK_NOFOLLOW)SYS_newfstatat (79), which Android permits. grep -c SYS_LSTAT ccgo_linux_arm64.go is 0. Go's own syscall package already uses fstatat on both architectures, which is why this is only the modernc path.

So: verify on arm64, and on this machine that means a real device. make android-smoke on an x86_64 AVD reports a SIGSYS tombstone that says nothing about your change.

Do not reach for an arm64 system image — it will not run here, and finding that out costs a 3.8 GB download. Emulator 37 refuses outright:

FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
        emulator on x86_64 host. System image must match the host
        architecture.

Google dropped cross-architecture emulation; there is no flag. The options are an arm64 host, a physical device, or adb connect to one.

The x86_64 ABI is therefore gone from the build (abiFilters in build/android/app/build.gradle, android:package rather than package:fat in the Makefile, and a native-code: 'arm64-v8a'$ assertion in android-apk.yml that fails if it comes back). It could not run on any Android until modernc fixes this — x86 Chromebooks included — and dropping it took the artifact from 27 MB to 15.9 MB. The tombstone was at least honest while it lasted: unlike the os.Exit that came before it, it left a real crash record with a backtrace.

The emulator still installs it, and it still does not run

The obvious guess about dropping x86_64 — that make android-install would now refuse with INSTALL_FAILED_NO_MATCHING_ABIS — is wrong, and was measured wrong before it was written down. Google's google_apis x86_64 images carry arm64 translation:

ro.product.cpu.abilist = x86_64,arm64-v8a

So the arm64-only APK installs, the loader maps lib/arm64/libwails.so and runs it (the tombstone says Guest architecture: 'arm64'). It then dies before any of our code, with SIGILL rather than SIGSYS:

signal 4 (SIGILL), code -6 (SI_TKILL)
#00 pc 00000000015911d0  .../lib/arm64/libwails.so

Disassembling that offset names the reason exactly:

15911d0: d5380600   mrs  x0, ID_AA64ISAR0_EL1

That is Go's internal/cpu reading the arm64 CPU-feature ID register at runtime init, which the translator does not implement. So it is not "our Go program is unlucky": no Go binary starts under this translation layer, and no amount of work on this app changes it.

The three failures are worth holding side by side, because each looks like the app's fault and none is:

build on x86_64 Android signal
x86_64 modernc's raw lstat vs seccomp SIGSYS, syscall 6
arm64, translated Go reads ID_AA64ISAR0_EL1 SIGILL
arm64, real device runs (2026-08-20)

A physical arm64 device remains the only verification path, and it has now been walked: a Light Phone III (TLP301, Android 14 / SDK 34, arm64-v8a, WebView Chrome 113 at 424x439). The app builds, installs, launches and stays up; make android-smoke SECONDS=60 passes on it. What that run found is the lifecycle fault below.

What was fixed to get here

backend/system's buildUserDirPath switched on runtime.GOOS with a default: returning errUnsupportedOS, so Android failed at startup and main() called os.Exit(1) six milliseconds after the bridge came up. main() now calls system.UseHomeOverride(application.Mobile. StoragePath()) before anything asks for a path — a documented, build-tag-free API that returns "" on desktop, where the setter is a no-op. backend/system gained no import of the Wails application package, which matters for the same reason backend/events is split by the indexbuild tag.

And main() is now latched to one run per process (#52). That is the second os.Exit(1) in this file's history and it had the same signature as the first, which is the argument for #160: both were named exactly by an slog line that went to /dev/null.

What is still not done

The shell is still a desktop shell, and the x86_64 half of the APK is still dead weight. Everything in plan 016's section A is now built: storage access, an in-app folder picker (Android's directory dialog returns an error, since the Storage Access Framework yields tree URIs rather than paths), MPRIS excluded, and a MediaSession with a transport notification and audio focus.

Compiling the android-tagged Go by hand

make lint and make test never see it: their three tag sets are all linux/amd64, so the only thing that compiles backend/mediacontrols/ android.go is make android — a full APK build for a Go type error. The short way round:

B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
  GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...

CXX is not optional. Without it the oboe C++ sources in oto compile against the host sysroot and fail on android/log.h and sys/system_properties.h, which reads like a broken or missing NDK. Restrict it to ./backend/...: ./... additionally builds build/android/gen, a scaffold shim that only resolves inside the wails task and fails with undefined: main on its own.

A Go method added to a bound service also reaches the frontend unless it says not to — //wails:ignore above the func, which make bindings then honours. Player.SetDuck is driven by OS audio focus and carries one.

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:

Do not run android:run:device or android:deploy-device against a device that has the released app on it (#159). Both begin with adb uninstall {{.APP_ID}}, and APP_ID defaults to app.yellowjacket — the release id — while run:device builds the debug variant, whose id is app.yellowjacket.dev. So it uninstalls the user's app, taking the library with it, installs a different package, and then fails to launch the one it removed. This is "the identity is declared twice" (below) cashing out. The safe sequence is at the end of this section.

wails3 task android:run              # debug build + emulator install + launch
wails3 task android:run:device       # UNSAFE, see #159
wails3 task android:deploy-device    # UNSAFE, see #159
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.

The safe way to put a debug build on a real device, which is what #52 used and what #159 exists to make unnecessary:

wails3 task android:build ARCH=arm64 && wails3 task android:assemble:apk
adb install -r bin/yellowjacket.apk      # -r, never uninstall
adb shell am start -n app.yellowjacket.dev/com.wails.app.MainActivity

YJ_ANDROID_PKG=app.yellowjacket.dev points scripts/android-emulator.sh — and therefore make android-smoke, android-logs, android-launch — at the debug id, which is otherwise app.yellowjacket.

What only a device can answer

The emulator cannot run this app (three separate reasons, none of them ours — see plan 016), so the phone in someone's pocket is a tier, and asking for it is cheap. The first run of it, on 2026-08-17, confirmed the whole of A4 and found two faults no other tier can see:

  • The back gesture. MainActivity.onBackPressed asks webView.canGoBack(). Nothing in a desktop shell has a back gesture, so no spec had ever called page.goBack() and the app had never pushed a history entry — back quit from any depth. It is a history entry per navigation now, which is also what made it assertable in the browser tier (e2e/specs/back-navigation.spec.ts).
  • The safe area. targetSdk 35 forces edge-to-edge, so the transport and the tab bar sat under the gesture bar. A browser viewport has no system bars: phone-shell.spec.ts at 390x844 will keep passing on a build the device is clipping 48dp off. Insets are handled in applyWindowInsets().

So when asking for a device run, ask about what the platform adds — system bars, the back gesture, focus and audio interruptions, permission dialogs, the keyboard — not about what the app draws. The drawing is what the other five tiers already cover.

The third such fault was the activity lifecycle (#52), and it is the one to re-check after touching main(), WailsBridge or MainActivity. Android destroys and recreates an activity without restarting the process, and Wails' nativeInit — which MainActivity.onCreate calls — runs go mainFunc() every time. So Go's main() ran again on a live app, app.Run() refused (a.starting is still true behind Android's select{}), and the os.Exit(1) under it took the healthy first app down with it.

The lifecycle check, and how to trigger it on demand

This is the regression guard for #52 on this tier, because no other tier runs main() on Android at all. The Go-side guard (TestMainClaimsBeforeItDoesAnything) catches work creeping above the latch; only the device catches the latch not working.

Trigger a relaunch with a configuration change the manifest does not declare. AndroidManifest.xml lists orientation|screenSize|keyboardHidden|uiMode, so those are handled in-place and are not triggers. fontScale is not listed, and it is a one-liner:

adb shell settings put system font_scale 1.15   # restore the old value after

That is the same in-process destroy/recreate that "Don't keep activities", a locale change and a memory trim produce, but on demand.

"Don't keep activities" is the report's own lever and did not work on this device: settings put global always_finish_activities 1 reads back as 1, am set-always-finish-activities does not exist on this build, and the activity was never finished on backgrounding. Do not spend an afternoon on it; use the config change.

The assertion is the pid, and the tell is two bridge inits in one.

adb logcat -d | grep -E "Wails bridge initialized|has died|finishDrawing of relaunch"

Healthy is one pid appearing twice — the process surviving the recreation:

I/WailsBridge(28420): Wails bridge initialized
I/WailsBridge(28420): Wails bridge initialized     <- same pid, recreated

Broken is that pair followed within a second by:

I/WindowManager: finishDrawing of relaunch: Window{...MainActivity} 603ms
I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg  TOP
W/ActivityTaskManager: Force removing ActivityRecord{...}: app died, no saved state

Two things about reading that. has died: fg TOP is not a memory kill — the system does not reclaim the foreground process, so this is the app leaving of its own accord. And there is no crash record anywhere: logcat -b crash is empty, no AndroidRuntime, no libc: Fatal signal, no tombstone. That is the os.Exit signature, and it is why "the system killed it" is the wrong first hypothesis.

Surviving is only half of it — check the recreated WebView is still wired to the running app. A plausible-looking fix (making WailsBridge.initialized static, so the second nativeInit is skipped) keeps the process alive and silently breaks this, because nativeInit is also what re-points the JNI reference at the new bridge. Go would go on executing JavaScript against the destroyed activity's WebView: the app opens, renders, and never receives another backend event.

Ask the page, after a relaunch and a resume:

make android-inspect
make android-eval EXPR='(()=>{window.__probe=[];const o=window._wails.dispatchWailsEvent.bind(window._wails);window._wails.dispatchWailsEvent=(e)=>{window.__probe.push(e&&e.name);return o(e)};return "ok"})()'
# background and foreground the app, then:
make android-eval EXPR='JSON.stringify(window.__probe)'

A healthy build answers with events from the live services — ["IndexStatusChanged","JobsChanged","JobsChanged","android:storageAccess"]. [] means the bridge reference is stale.

Asking the device, not just looking at it

A real phone can be inspected, and that turns this tier from "reported symptoms" into evidence. Three commands:

make android-screenshot          # what the screen shows (.dev/ by default)
make android-inspect             # forward the WebView's devtools socket
make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'

Four things about it, each of which costs an hour if met cold:

  • Only a debuggable build has a devtools socket, and a debug build carries applicationIdSuffix ".dev" so it installs beside the release app. That matters more than convenience: the two are signed by different certificates, and Android's only remedy for a changed certificate is an uninstall, which takes the user's library with it. Never uninstall to make room for a build.
  • Playwright cannot drive it. connectOverCDP calls Browser.setDownloadBehavior, a WebView answers "Browser context management is not supported", and the connection dies before the first evaluate. scripts/android-eval.mjs is raw CDP over Node's built-in WebSocket for that reason.
  • Wireless adb drops when the screen sleeps. The symptoms are device offline mid-session and a fetch failed from the eval script. Plug in over USB for anything longer than a couple of probes.
  • The socket name carries the pid, which changes on every launch, so it is resolved rather than remembered.

And the reason to bother: the phone is an engine, not a screen. The first device here renders in Chrome 113 at 424x439 CSS px. Every other tier runs a current Chromium or WebKit, so a spec that passes at that viewport says nothing about the phone — 113 has no Popover API and no relaxed CSS nesting, and a dropped CSS declaration renders as "present but wrong", which is the hardest failure to read from a picture. Get the version first; it reframes every other symptom.