Files
yellowjacket/.pi/skills/yellowjacket-dev/references/android-tier.md
T
logan 68468e5378 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.
2026-08-16 15:31:18 -04:00

8.1 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                # 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.