docs(android): the device can be driven, not just looked at
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m31s
CI / e2e (pull_request) Successful in 7m59s

The runtime call does not go over HTTP on Android — the WebView cannot
deliver a fetch() POST body to shouldInterceptRequest, so v3 routes
runtime calls through the addJavascriptInterface bridge. Two things
follow that cost an hour each before the v3 source was read:
`.playwright/init-events.js` does not transfer to the device (its
outbound half hooks fetch, and a POST to /wails/runtime answers
"missing object value" — which reads like a wrong payload and is the
interceptor getting no body at all), and hooking fetch from an eval is
too late on any platform because the bundle captured its reference at
module scope.

The recipe that does work goes in, along with how to get audio onto the
phone (scoped storage silently swallows a push into
/sdcard/Android/data/<pkg>/files, and the fixtures are 2 seconds long,
which is useless for watching a seek bar) and the permission dialog a
reinstall raises, which looks exactly like the app failing to start.

NOTES.md takes the #53 measurements: that its frontend is byte-identical
to the v0.3.1 the phone carries, that the symptom does not reproduce on
main in four scenarios, and that reverting only backend/player/ to
v0.3.1 reproduces #125 instead — with the shim that makes that a
ten-minute experiment rather than a full checkout.
This commit is contained in:
2026-08-20 17:17:33 -04:00
parent fe1fbefee7
commit 67eeb75e7b
2 changed files with 129 additions and 0 deletions
@@ -515,6 +515,75 @@ Four things about it, each of which costs an hour if met cold:
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.
### 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
+60
View File
@@ -4242,3 +4242,63 @@ took another ~10 s to come up.
Harmless here (the emulator was up before anything used it) and a
straightforward race otherwise: `start` should wait for a device that
is an emulator, not for whatever `pick_device` returns. Filed as #162.
## The Android runtime transport is not HTTP (measured 2026-08-20)
Found while trying to drive the phone for #53. `wails3` routes runtime
calls through `addJavascriptInterface` on Android, not through
`/wails/runtime` — the WebView cannot deliver a `fetch()` POST body to
`shouldInterceptRequest`, which the v3 source says in as many words
(`application_android.go`, "The Android transport"). The runtime
installs a `customTransport` over `window.wails.invokeAsync(id,
payload)` and takes the answer on `window._wailsAndroidCallback`.
Two things follow, and both cost time before the source was read:
- **`.playwright/init-events.js` does not transfer to the device.** Its
outbound half hooks `fetch`; a POST to `/wails/runtime` answers
`Invalid runtime call: missing object value`, which reads like a
wrong payload shape and is actually the interceptor receiving a URL
with no body at all. The payload shape was right the whole time. Its
*inbound* half is still correct, because `dispatchWailsEvent` is the
entry point in every mode.
- **Hooking `fetch` from an `eval` is too late on any platform.** The
bundle captured its reference at module scope, so a wrapper installed
afterwards records nothing — which is exactly why the harness is an
`initScript`. Measured: zero calls captured while the app was
demonstrably making them.
The working recipe is in `android-tier.md`; it chains the runtime's own
callback rather than replacing it, so its pending calls still resolve.
This is what makes the device a tier that can be *driven*.
## #53's frontend is byte-identical to the build it was reported against (2026-08-20)
`git diff v0.3.1 HEAD -- frontend/src/components/audio-player/seekbar/
frontend/src/store/player-store.ts` is **empty**; the whole diff in that
area is `backend/player/`. The phone carries the released `v0.3.1`, so
whatever #53 saw, the component was not what changed — and v0.4.0 is
where the player audit (#122#127) landed.
Measured on that phone, current `main`, with a synthesised 4-minute
track: the Now Playing seek bar tracks correctly when mounted
mid-playback (`seekValue` 28 of 240), when the view is opened before
playback starts, after a tap on the track, and across an activity
recreation (same pid, bar resumes at 30 → 35). The issue's stated
symptom did not reproduce in any of them.
Reverting **only** `backend/player/` to v0.3.1 — the frontend and
everything else at HEAD — does reproduce a real position defect on the
same device: six seconds into a 20-second file with no database row,
played after a 240-second one, the bar read **01:27 of 240**. That is
#125's stale `trackLengthMs` ("cleared only by UnloadTrack, so a file
with no row inherited the previous track's duration"), and it is fixed
at HEAD. Note the *shape* of it: the fraction is roughly right and the
absolute numbers are wrong, so it presents as a clock that lies rather
than as a handle that will not move.
The one-line experiment is worth remembering: v0.3.1's `backend/player`
compiles against HEAD with a single shim
(`SetPlaybackFinishedHandler` gained a `srcErr error` parameter), which
makes "did the backend fix cause this" a ten-minute question instead of
a full checkout.