Every slog line the app wrote on Android went to /dev/null, including the one naming the error it was about to os.Exit on. #52 is what that cost: a process that vanished with no tombstone, no AndroidRuntime stack and nothing in `logcat -b crash`, at Priority/Critical for months, whose entire diagnosis was one sLogger.Error main.go was already writing. backend/androidlog is a slog.Handler over __android_log_write, chosen in main() by build tag rather than by a runtime check so that a desktop binary links no cgo for a platform it cannot run on. **Everything except the write itself is untagged.** That is androidpayload.go's discipline pushed as far as it goes: the only toolchain that compiles the android tag is a cross-compiler and the only thing that runs it is a phone, so the priority mapping, the formatting, the chunking and the handler's own attr and group bookkeeping are ordinary Go that `go test` exercises everywhere, and android.go is fifteen lines that hand a string to liblog. Four things in it are load-bearing. **The tag is a fixed string, not the application id.** The debug build carries `applicationIdSuffix ".dev"` so it can be installed beside the release app, and it is the only build whose WebView can be inspected -- so a tag derived from the id is a different tag on the one build anybody debugging this app is running, and the filter meant to show these lines would hide them exactly where they were being looked for. **The priorities are android/log.h's own values, asserted twice.** android.go carries constant expressions that do not compile as uint if the header renumbers; the untagged test writes the six numbers out longhand, because comparing a constant to itself passes on any renumbering. A wrong priority is the failure that hides rather than breaks -- logcat prints whatever number it is handed, so an Error filed as Info is present, correct, and invisible to every filter. **Formatting is delegated to slog's TextHandler.** WithAttrs and WithGroup are the half of slog.Handler that is easy to get subtly wrong, and a logger whose groups are wrong is a logger nobody reads. The derived handlers share the parent's buffer *and its mutex*: a second mutex would guard nothing, and two loggers derived from one would splice their bytes into a single line under load. **A line is chunked, because liblog drops what does not fit.** The kernel logger's entry is 4068 bytes for tag and message together and the remainder goes without comment, so a long record would be truncated in the middle of the thing worth reading. Time and level are dropped from the formatted line, since logcat stamps every entry with both -- and dropping them by *key* also ate a caller's own "level" attribute, which the on-device probe caught and TestACallersOwnLevelAttrSurvives now holds. ReplaceAttr sees an empty group path for the built-ins and for every top-level attribute alike, so the kinds are what separate them. Verified on the reference device (TLP301, Android 14): a debug build logs I/W/E under the `yellowjacket` tag at the right priorities, and the first thing it surfaced was a real warning nobody could previously see -- `champion index rebuild failed ... disk I/O error (6410)`. Closes #160
669 lines
31 KiB
Markdown
669 lines
31 KiB
Markdown
# 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).
|
|
|
|
## Two facts that make failure invisible
|
|
|
|
There were three. The first was that **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 wrote was discarded, including the one naming the
|
|
error it was about to exit on. That is fixed (#160):
|
|
`backend/androidlog` is a `slog.Handler` over `__android_log_write`,
|
|
selected in `main()` by build tag, and the app's whole diagnostic
|
|
stream now arrives under the `yellowjacket` tag, which `make
|
|
android-logs` filters for.
|
|
|
|
What remains true about it is the part that misleads: **`setprop
|
|
log.redirect-stdio true` still does not help**, because it redirects
|
|
the *Java* runtime's `System.out` and the Go code is a c-shared native
|
|
library. Nothing that reaches logcat here does so through stdout, so
|
|
anything printed with `fmt.Println` is still lost. Log with `slog`.
|
|
|
|
The tag is a fixed string rather than the application id, and that is
|
|
load-bearing rather than tidy: the debug build carries
|
|
`applicationIdSuffix ".dev"` so it can be installed beside the release
|
|
app, and it is the only build whose WebView can be inspected — so a tag
|
|
derived from the id would be filtered out on the one build anybody
|
|
debugging this app is running.
|
|
|
|
**`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 — and
|
|
since #160, **read the `E/yellowjacket` line above it first**, because
|
|
every one of those paths logs the error before it exits. That line is
|
|
what #52 spent months without.
|
|
|
|
## What to run
|
|
|
|
One-time, ~3.5 GB:
|
|
|
|
```bash
|
|
make android-setup # SDK pieces + the yj-test AVD, idempotent
|
|
```
|
|
|
|
Then:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
> **These four were unsafe until #159 and are now the way in.** All of
|
|
> them began with `adb uninstall {{.APP_ID}}`, where `APP_ID` defaulted
|
|
> to `app.yellowjacket` — the **release** id — while `run` and
|
|
> `run:device` build the **debug** variant, whose id is
|
|
> `app.yellowjacket.dev`. So they uninstalled the user's app, taking
|
|
> the library with it, installed a different package, and then failed
|
|
> to launch the one they had removed.
|
|
>
|
|
> They share `scripts/android-deploy.sh` now, which **never**
|
|
> uninstalls (`install -r`, and a changed signing certificate is
|
|
> reported with the command rather than acted on), reads the package id
|
|
> back out of the built APK, and refuses a target that is not the kind
|
|
> the task names. There is nothing left to avoid; the manual sequence
|
|
> below is kept because it is still the smallest thing that works.
|
|
|
|
```
|
|
wails3 task android:run # debug build + emulator install + launch
|
|
wails3 task android:run:device # debug build + install + launch on a phone
|
|
wails3 task android:deploy-device # release build, same
|
|
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
|
|
```
|
|
|
|
**`run` and `deploy-emulator` mean the emulator, and now say so to
|
|
adb.** They used a bare `adb install`, which with exactly one device
|
|
attached picks that device whatever it is — so with a phone plugged in
|
|
and no emulator running, the task whose summary reads "in the Android
|
|
Emulator" installed on the phone. They pass `--target emulator` and
|
|
refuse with `make android-emulator` as the remedy.
|
|
|
|
**`DEVICE_ID=<serial>` still names a device, and several attached
|
|
devices is now an error rather than a silent pick of the first.**
|
|
|
|
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 read back from the APK
|
|
|
|
It used to be **declared twice**, and that is what #159 was.
|
|
`applicationId` in `build/android/app/build.gradle` is what Gradle
|
|
installs; `APP_ID` in `build/android/Taskfile.yml` was what every
|
|
adb-driven task uninstalled, launched and filtered, and nothing
|
|
enforced that they agree. They did not: the debug buildType carries
|
|
`applicationIdSuffix ".dev"`, so every task that assembles a debug APK
|
|
addressed the release id. This file flagged the hazard for five phases
|
|
and it cashed out twice — once as a wrong `am start`, once as an
|
|
uninstall of the user's library.
|
|
|
|
**`scripts/android-pkgid.sh` is the one answer now.** It prints the
|
|
package id an APK declares (`aapt2 dump packagename`, falling back to
|
|
`aapt dump badging`), and the deploy path installs and launches *that*.
|
|
The APK is the authority because the task that installs it has just
|
|
built it: whatever Gradle resolved the applicationId to, suffixes and
|
|
flavours included, is in the file, and no default can disagree with it.
|
|
An APK it cannot read is a hard failure, never a fallback to a written
|
|
down default — guessing is the bug.
|
|
|
|
**`APP_ID` survives as an assertion, not a setting**, and has no
|
|
default. `wails3 task android:run APP_ID=app.yellowjacket` says "this
|
|
build had better declare that id" and is refused, naming both, *before*
|
|
anything is installed or a device is even chosen. It could never have
|
|
been a setting: `ANDROID.md`'s advice to put it in `build/config.yml`
|
|
does not work in beta.8 — `wails3 task` never reads that file (verified
|
|
with `--dry`) — and even when set it fed only the adb commands, never
|
|
Gradle.
|
|
|
|
`scripts/android-emulator.sh` derives `PKG` the same way, from
|
|
`bin/yellowjacket.apk` when one is built, so `make android-install`,
|
|
`android-launch`, `android-logs` and `android-smoke` follow whichever
|
|
variant is actually in `bin/`. `YJ_ANDROID_PKG` still overrides, and
|
|
the old literal survives only for a tree with no APK built yet.
|
|
|
|
**The uninstall is gone and is not coming back.** It existed to make
|
|
the bare `install` on the next line work at all — without `-r` Android
|
|
refuses an install over an existing package — so `install -r` removes
|
|
the *reason* for it rather than merely removing it. What is left is the
|
|
one case an uninstall really is the remedy, a changed signing
|
|
certificate, and that is exactly the case where performing it silently
|
|
costs the user their library. So it is named and not done, which is the
|
|
answer `scripts/android-emulator.sh` had already reached for
|
|
`make android-install`.
|
|
|
|
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.
|
|
|
|
**`wails3 task android:run:device` is the way to put a debug build on a
|
|
real device**, since #159. What #52 used, before it was safe, was the
|
|
longer form, and it is still the smallest thing that works if you want
|
|
no script between you and adb:
|
|
|
|
```bash
|
|
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
|
|
```
|
|
|
|
The id in that last line is the one thing to keep an eye on by hand —
|
|
`./scripts/android-pkgid.sh bin/yellowjacket.apk` is what the tasks ask,
|
|
and it is a good habit before any `am start` written out in full.
|
|
|
|
`YJ_ANDROID_PKG=app.yellowjacket.dev` still overrides what
|
|
`scripts/android-emulator.sh` — and therefore `make android-smoke`,
|
|
`android-logs`, `android-launch` — addresses, but it is rarely needed
|
|
now: that default is read from `bin/yellowjacket.apk`, so it already
|
|
follows whichever variant was built last.
|
|
|
|
## 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:
|
|
|
|
```bash
|
|
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.**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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.
|
|
- **A reinstall resets the runtime permissions**, and the grant dialog
|
|
is a separate activity that takes focus — so the app is up, `am start`
|
|
reports "delivered to currently running top-most instance", and
|
|
`pidof` is empty because it never got to the foreground.
|
|
`dumpsys window | grep mCurrentFocus` naming
|
|
`GrantPermissionsActivity` is the tell. `adb shell pm grant
|
|
app.yellowjacket.dev android.permission.READ_MEDIA_AUDIO` (and
|
|
`POST_NOTIFICATIONS`) ahead of the launch skips it.
|
|
|
|
### Getting the app into a state worth measuring
|
|
|
|
A fresh install is **not** a neutral starting point, and three things
|
|
about it will each cost you a measurement.
|
|
|
|
**It downloads the real catalog.** `YJ_CORE_INDEX_URL` is stubbed in
|
|
`dev-headless.sh` and in CI and is *real* here, so the app spends its
|
|
first minutes fetching ~0.6 GB and `job-band` is **103px of a 439px
|
|
screen** while it does. Every vertical number taken in that state is
|
|
wrong -- one #51 measurement had the album art at 0px and it was
|
|
entirely this.
|
|
|
|
`__yj.call("explore.Service.StopIndexBuild", [])` stops it and returns
|
|
cleanly. **It then starts again within seconds.** So stop it
|
|
*immediately before* the measurement rather than once at the beginning,
|
|
and check `jobs.Service.GetJobs` afterwards -- an empty array is the
|
|
only proof. `jobs.Service.ClearFinishedJobs` tidies the finished rows
|
|
that otherwise keep the band open.
|
|
|
|
**A library added over the bridge does not dismiss the first-run
|
|
wizard.** `library.Library.AddLibrary` works and scans, but the wizard
|
|
checks for an existing library once, on mount, and its "Get Started"
|
|
button gates on a directory chosen *in the wizard* -- so it stays up
|
|
with a correctly disabled button over everything you are trying to
|
|
measure. Nothing is broken; reload the page and it is gone. This reads
|
|
exactly like a tap being swallowed, which is the expensive part.
|
|
|
|
**Scoped storage decides where the music can be.** `/sdcard/Music/...`
|
|
plus `pm grant <pkg> android.permission.READ_MEDIA_AUDIO` works and
|
|
`AddLibrary` takes the plain path; a push into
|
|
`/sdcard/Android/data/<pkg>/files/` looks like it worked and then is not
|
|
there. Some builds additionally want
|
|
`appops set <pkg> MANAGE_EXTERNAL_STORAGE allow`, and until they have it
|
|
the app opens the *system* "All files access" screen on launch -- so
|
|
`dumpsys window | grep mCurrentFocus` naming `com.android.settings` is
|
|
that, not a crash.
|
|
|
|
### A note on quoting `make android-eval`
|
|
|
|
`EXPR='...'` is a single-quoted shell word, so anything with a quote or
|
|
an apostrophe in it -- a file path like `Blazo, 49'ers - ...`, or a
|
|
snippet containing a string literal -- breaks in a way that reads as a
|
|
JavaScript error. Put the expression in a file and pass it positionally:
|
|
|
|
```bash
|
|
node ./scripts/android-eval.mjs "$(cat /tmp/probe.js)"
|
|
```
|
|
|
|
That is the same script `make android-eval` wraps, so nothing is lost.
|
|
Two things worth knowing about it: it does **not** await a promise, so
|
|
an async call has to park its result (`window.__r = ...`) and be read
|
|
back in a second eval; and the shim from the section below is lost on
|
|
every reload and every app restart, along with the devtools socket,
|
|
whose name carries the pid.
|
|
|
|
### Calling a binding on the device
|
|
|
|
**The runtime call does not go over HTTP on Android**, and this is worth
|
|
knowing before an hour is spent on it. The WebView cannot deliver a
|
|
`fetch()` POST body to `shouldInterceptRequest`, so v3 routes runtime
|
|
calls through the `addJavascriptInterface` bridge instead: the
|
|
@wailsio/runtime installs a `customTransport` that calls
|
|
`window.wails.invokeAsync(id, payload)` and receives the answer on
|
|
`window._wailsAndroidCallback`. Two consequences:
|
|
|
|
- **`.playwright/init-events.js` does not transfer to the device.** Its
|
|
outbound half hooks `fetch`, which sees nothing here, and its
|
|
`call()` posts to `/wails/runtime`, which answers
|
|
`Invalid runtime call: missing object value` — the interceptor got the
|
|
URL with no body. Its *inbound* half is still right, because
|
|
`dispatchWailsEvent` is the entry point in every mode.
|
|
- **Hooking `fetch` from an eval is too late anyway**, on any platform:
|
|
the bundle captured its reference at module scope, so a wrapper
|
|
installed afterwards records nothing. That is why the harness is an
|
|
`initScript` and not a step in a spec.
|
|
|
|
What works is to borrow the bridge, chaining the runtime's own callback
|
|
so its pending calls still resolve:
|
|
|
|
```js
|
|
const pending = new Map();
|
|
const prev = window._wailsAndroidCallback;
|
|
window._wailsAndroidCallback = (id, response, error) => {
|
|
if (!pending.has(id)) return prev && prev(id, response, error);
|
|
const p = pending.get(id); pending.delete(id);
|
|
const env = JSON.parse(response || "{}");
|
|
return env.ok ? p.resolve(env.data ?? env.text) : p.reject(new Error(env.error));
|
|
};
|
|
window.__yj = { call(name, args) {
|
|
return new Promise((resolve, reject) => {
|
|
const id = "yj" + Math.random().toString(36).slice(2);
|
|
pending.set(id, { resolve, reject });
|
|
window.wails.invokeAsync(id, JSON.stringify({
|
|
object: 0, method: 0, windowName: "",
|
|
args: { "call-id": id, methodName: "yellowjacket/backend/" + name, args: args || [] },
|
|
clientId: window._wails.clientId,
|
|
}));
|
|
});
|
|
} };
|
|
```
|
|
|
|
That turns the device into a tier that can be *driven* rather than only
|
|
looked at — `__yj.call("player.Player.LoadFile", [path])` and
|
|
`__yj.call("library.Library.AddLibrary", ["/sdcard/Music/..."])` are how
|
|
#53 was measured. Names are the Go ones (`GetTracks`, not
|
|
`GetAllTracks`); an unknown one comes back as a plain
|
|
`unknown bound method name`, so a wrong guess is loud.
|
|
|
|
**Getting audio onto the phone**: `adb push` into
|
|
`/sdcard/Android/data/<pkg>/files/` looks like it works and then the
|
|
files are not there — scoped storage. `/sdcard/Music/...` plus
|
|
`pm grant … READ_MEDIA_AUDIO` does work, and `AddLibrary` takes the
|
|
plain path. The generated fixtures are **~2 seconds** each, which is
|
|
fine for a scan and useless for watching a seek bar, so synthesise a
|
|
long one: `ffmpeg -f lavfi -i sine=frequency=440:duration=240`.
|
|
|
|
**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.
|