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
This commit is contained in:
@@ -194,9 +194,13 @@ like the app's fault and none is:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||||
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||||
| arm64, real device | — | unverified, still |
|
| arm64, real device | **runs** (2026-08-20) | — |
|
||||||
|
|
||||||
**A physical arm64 device remains the only verification path.**
|
**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
|
### What was fixed to get here
|
||||||
|
|
||||||
@@ -210,6 +214,11 @@ no-op. `backend/system` gained no import of the Wails application
|
|||||||
package, which matters for the same reason `backend/events` is split by
|
package, which matters for the same reason `backend/events` is split by
|
||||||
the `indexbuild` tag.
|
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
|
### What is still not done
|
||||||
|
|
||||||
The shell is still a desktop shell, and the x86_64 half of the APK is
|
The shell is still a desktop shell, and the x86_64 half of the APK is
|
||||||
@@ -249,10 +258,20 @@ one.
|
|||||||
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
|
`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:
|
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 # debug build + emulator install + launch
|
||||||
wails3 task android:run:device # same, first connected physical device
|
wails3 task android:run:device # UNSAFE, see #159
|
||||||
wails3 task android:deploy-device # production APK to a device
|
wails3 task android:deploy-device # UNSAFE, see #159
|
||||||
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
||||||
wails3 task android:studio # open build/android/ in Android Studio
|
wails3 task android:studio # open build/android/ in Android Studio
|
||||||
wails3 task android:device:list
|
wails3 task android:device:list
|
||||||
@@ -287,6 +306,19 @@ resolves the leading dot against the *applicationId* and fails with a
|
|||||||
class-not-found that reads like a broken build. Always the
|
class-not-found that reads like a broken build. Always the
|
||||||
fully-qualified form.
|
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:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
## What only a device can answer
|
||||||
|
|
||||||
The emulator cannot run this app (three separate reasons, none of them
|
The emulator cannot run this app (three separate reasons, none of them
|
||||||
@@ -311,6 +343,91 @@ system bars, the back gesture, focus and audio interruptions,
|
|||||||
permission dialogs, the keyboard — not about what the app draws. The
|
permission dialogs, the keyboard — not about what the app draws. The
|
||||||
drawing is what the other five tiers already cover.
|
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
|
## Asking the device, not just looking at it
|
||||||
|
|
||||||
A real phone can be inspected, and that turns this tier from "reported
|
A real phone can be inspected, and that turns this tier from "reported
|
||||||
|
|||||||
@@ -4077,3 +4077,81 @@ have saved the other two cycles.
|
|||||||
first track with no cover art" selects nothing in particular. The
|
first track with no cover art" selects nothing in particular. The
|
||||||
placeholder's presence is asserted instead, which is the property the
|
placeholder's presence is asserted instead, which is the property the
|
||||||
test actually depends on.
|
test actually depends on.
|
||||||
|
|
||||||
|
## An activity recreation kills the process, deterministically (measured 2026-08-20)
|
||||||
|
|
||||||
|
#52's report was "sometimes crashes or restarts when reopened after
|
||||||
|
running in the background". Measured on a real device, the *fault* is
|
||||||
|
not intermittent at all — only its trigger is.
|
||||||
|
|
||||||
|
Device: Light Phone III (TLP301), Android 14 / SDK 34, arm64-v8a,
|
||||||
|
WebView **Chrome 113** at 424x439 CSS px. Debug build
|
||||||
|
(`app.yellowjacket.dev`), installed beside the released `v0.3.1` with
|
||||||
|
`install -r`.
|
||||||
|
|
||||||
|
**Conditional on the activity actually being recreated in a live
|
||||||
|
process, the process died 8 times out of 8** — 3 by hand, then 5/5 in a
|
||||||
|
scripted loop. The runs where it survived were runs where no recreation
|
||||||
|
happened (one `Wails bridge initialized` in the log rather than two), so
|
||||||
|
they are inconclusive rather than passes; a harness that does not check
|
||||||
|
for the second init reports those as green and reads as flakiness.
|
||||||
|
After the fix: 5/5 recreations survived, plus 6 background/foreground
|
||||||
|
cycles and 3 interleaved recreations on one pid.
|
||||||
|
|
||||||
|
The mechanism is three log lines:
|
||||||
|
|
||||||
|
```
|
||||||
|
12:47:56.159 I/WailsBridge(22956): Wails bridge initialized
|
||||||
|
12:48:38.898 I/WailsBridge(22956): Wails bridge initialized <- same pid
|
||||||
|
12:48:39.357 I/ActivityManager: Process app.yellowjacket.dev (pid 22956) has died: fg TOP
|
||||||
|
```
|
||||||
|
|
||||||
|
`nativeInit` runs `go mainFunc()` on every activity creation; the second
|
||||||
|
`main()` reaches `app.Run()`, which refuses because `a.starting` is
|
||||||
|
still true behind Android's `select{}`, and `os.Exit(1)` takes the whole
|
||||||
|
process — including the healthy first app — with it.
|
||||||
|
|
||||||
|
Four things worth keeping:
|
||||||
|
|
||||||
|
- **`has died: fg TOP` is not a memory kill.** The system does not
|
||||||
|
reclaim the foreground process. This reads as "the OS killed us",
|
||||||
|
which is the wrong hypothesis and the reason the issue sat unverified.
|
||||||
|
- **There is no crash record of any kind**: `logcat -b crash` empty, no
|
||||||
|
`AndroidRuntime`, no `libc: Fatal signal`, no tombstone. `os.Exit` is
|
||||||
|
not a crash. The one line that named the fault —
|
||||||
|
`slog.Error("application error", "err", ...)`, carrying
|
||||||
|
`"application is running or a previous run has failed"` — went to
|
||||||
|
`/dev/null`. That is #160.
|
||||||
|
- **"Don't keep activities" does 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. The report's own
|
||||||
|
suggested lever is a dead end here. What *does* work, deterministically
|
||||||
|
and in one line, is a configuration change the manifest does not
|
||||||
|
declare: `adb shell settings put system font_scale 1.15`
|
||||||
|
(`AndroidManifest.xml` declares `orientation|screenSize|
|
||||||
|
keyboardHidden|uiMode`, so none of those are triggers).
|
||||||
|
- **Surviving is only half the property.** The recreated WebView has to
|
||||||
|
still be wired to the running app, which was verified by hooking
|
||||||
|
`window._wails.dispatchWailsEvent` and backgrounding/foregrounding:
|
||||||
|
`["IndexStatusChanged","JobsChanged","JobsChanged",
|
||||||
|
"android:storageAccess"]`. The tempting Java-side fix — making
|
||||||
|
`WailsBridge.initialized` static — passes the pid check and fails
|
||||||
|
this one, because `nativeInit` is also what re-points the JNI
|
||||||
|
reference at the new bridge.
|
||||||
|
|
||||||
|
## The Taskfile's device tasks uninstall the released app (2026-08-20)
|
||||||
|
|
||||||
|
`android:run:device` builds the **debug** variant
|
||||||
|
(`applicationIdSuffix ".dev"`) and then runs
|
||||||
|
`adb uninstall {{.APP_ID}}`, where `APP_ID` defaults to
|
||||||
|
`app.yellowjacket` — the **release** id. So it deletes the user's
|
||||||
|
installed app and its library, installs a different package, and then
|
||||||
|
fails to launch the one it removed. `deploy-device` carries the same
|
||||||
|
uninstall. Filed as #159; `android-tier.md` had been recommending
|
||||||
|
`run:device` as the way onto a device.
|
||||||
|
|
||||||
|
This is the hazard that file already names — "The identity is declared
|
||||||
|
twice ... **Nothing enforces that they agree**" — reached by a second
|
||||||
|
route: the two ids differ not because someone edited one, but because
|
||||||
|
the debug buildType suffixes it.
|
||||||
|
|||||||
@@ -317,6 +317,60 @@ stacking dialogs. Window state moved off that path entirely, onto a
|
|||||||
window still exists and `OnShutdown` has neither a context nor a
|
window still exists and `OnShutdown` has neither a context nor a
|
||||||
window.
|
window.
|
||||||
|
|
||||||
|
**An activity is a view onto the process, and `main()` runs once per
|
||||||
|
process.** On Android the Wails entry point is `nativeInit`, which
|
||||||
|
`MainActivity.onCreate` calls — and it does two things: it re-points the
|
||||||
|
native library's global JNI reference at the calling `WailsBridge`, and
|
||||||
|
it runs `go mainFunc()`. Android destroys and recreates an activity
|
||||||
|
**without restarting the process** (a configuration change the manifest
|
||||||
|
does not declare, memory pressure, or every background under "Don't keep
|
||||||
|
activities"), so `main()` ran again on a live app. `application.New`
|
||||||
|
returns the *existing* app rather than building a second one,
|
||||||
|
`app.Run()` then refuses — `a.starting` is still true, because Android's
|
||||||
|
`platformRun` is `select{}` and never returns — and the `os.Exit(1)`
|
||||||
|
under that error took the **first**, healthy app down with it: its
|
||||||
|
database, its queue, and the audio a `mediaPlayback` foreground service
|
||||||
|
was holding the process alive to play. `mainStarted` latches it, first
|
||||||
|
statement in `main()`.
|
||||||
|
|
||||||
|
Four things about it are load-bearing.
|
||||||
|
|
||||||
|
**The answer to "restore the session or cold-start" is settled by
|
||||||
|
playback, not by preference.** The audio lives in the Go process, so a
|
||||||
|
cold start on every activity recreation would stop the music mid-song —
|
||||||
|
which is the exact thing the foreground service exists to prevent. The
|
||||||
|
activity is a view; the app is the process. The frontend already
|
||||||
|
cooperates, because a recreated WebView loads the page fresh and fetches
|
||||||
|
its state from a backend that never went away.
|
||||||
|
|
||||||
|
**Returning early is not a degraded mode, and that is why the latch is
|
||||||
|
in Go rather than in Java.** The obvious fix — making
|
||||||
|
`WailsBridge.initialized` `static`, so the second `nativeInit` is
|
||||||
|
skipped — keeps the process alive and silently breaks the app, because
|
||||||
|
skipping `nativeInit` skips the reference re-point too: Go would keep
|
||||||
|
executing JavaScript against the *destroyed* activity's WebView, and the
|
||||||
|
app would open, render, and never receive another backend event. The
|
||||||
|
latch lets `nativeInit` do its first job and declines only its second.
|
||||||
|
|
||||||
|
**`ServiceShutdown` has never run on Android**, and nothing should be
|
||||||
|
built on the assumption that it will. `App.Quit()` reaches an
|
||||||
|
`androidApp.destroy()` that is an empty method, and `Run()`'s deferred
|
||||||
|
`shutdownServices()` cannot fire behind `select{}`. Durability on this
|
||||||
|
platform is the persist writers, which submit on every mutation rather
|
||||||
|
than at exit — which is also why `MainActivity.onDestroy` no longer
|
||||||
|
calls `bridge.shutdown()`: the activity going away is not the app
|
||||||
|
shutting down, and there is no callback for the process going away
|
||||||
|
because Android simply kills it.
|
||||||
|
|
||||||
|
**No tier here can see any of this**, so the guard is split. A source
|
||||||
|
sweep (`TestMainClaimsBeforeItDoesAnything`) asserts the latch is the
|
||||||
|
*first* statement of `main()` — the failure it exists for is not
|
||||||
|
deletion, which is loud, but a line creeping in above it, since a second
|
||||||
|
`NewYellowJacketApp` opens the SQLite database again on every
|
||||||
|
recreation. The rest is a documented device check in
|
||||||
|
`.pi/skills/yellowjacket-dev/references/android-tier.md`, with the
|
||||||
|
logcat signature and a one-line way to force a recreation.
|
||||||
|
|
||||||
`internalServiceMethods` auto-excludes `ServiceStartup`,
|
`internalServiceMethods` auto-excludes `ServiceStartup`,
|
||||||
`ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this
|
`ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this
|
||||||
shape **removed** 12 spurious bindings and the bogus `context` model
|
shape **removed** 12 spurious bindings and the bogus `context` model
|
||||||
|
|||||||
Reference in New Issue
Block a user