docs(android): the device can be driven, not just looked at
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user