diff --git a/.pi/skills/yellowjacket-dev/references/android-tier.md b/.pi/skills/yellowjacket-dev/references/android-tier.md index 24588cc..01804fa 100644 --- a/.pi/skills/yellowjacket-dev/references/android-tier.md +++ b/.pi/skills/yellowjacket-dev/references/android-tier.md @@ -310,3 +310,41 @@ 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. + +## 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. + +**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. diff --git a/.planning/NOTES.md b/.planning/NOTES.md index 03730d9..bc5b1e3 100644 --- a/.planning/NOTES.md +++ b/.planning/NOTES.md @@ -3138,3 +3138,73 @@ cover the history behaviour, on Chromium locally and WebKit in CI. Not verified: the insets themselves, which need the next APK on the owner's phone. What to look for is one thing — the transport and the tab bar clear of the gesture bar, and the header clear of the status bar. + +## The phone is a Chrome 113 WebView, and that reframes everything (2026-08-17) + +The device is reachable over adb now, so the tier can be *asked* rather +than reported on. `make android-inspect` + `make android-eval` are that: +a debug build (`applicationIdSuffix ".dev"`, so it installs **beside** +the release app rather than needing the uninstall that would take the +library with it) opens `webview_devtools_remote_`, and raw CDP over +Node's built-in WebSocket evaluates in the real page. **Playwright +cannot do this** — `connectOverCDP` calls `Browser.setDownloadBehavior` +and a WebView answers "Browser context management is not supported", +killing the connection before the first evaluate. + +Measured on the device (Light Phone III, TLP301): + +| fact | value | +| --- | --- | +| Android | 14, SDK 34 | +| screen | 1080x1240, density 408 | +| WebView viewport | **424 x 439 CSS px**, DPR 2.55 | +| WebView engine | **Chrome 113.0.5672.136** (mid-2023) | + +**The first correction: the insets commit does not explain the report.** +Edge-to-edge is forced for apps *running on* Android 15, and this phone +is Android 14 — the screenshot shows the app correctly inset, with the +status bar and the gesture bar outside it. `applyWindowInsets()` is +right and stays (the next phone, or one OS update, is Android 15), but +it is **pre-emptive, not the fix for "the controls are off screen"**. +That was an inference from a version number, and the device disagreed. + +**The second correction: the black `fill` proves nothing.** A wa-icon on +the device has the right `color` (#ffd43b) and an `` in its shadow +root, and `getComputedStyle(svg).fill` is black — but that is the *svg +root*, and every vendored Font Awesome path carries +`fill="currentColor"` itself, so the root's fill is irrelevant. Measuring +the wrong node produced a diagnosis-shaped result. `__yjIconMisses` is +empty, so no name is unbundled either. Why the icons do not appear in the +screenshot is **still open**. + +**What the engine version does explain, and what to check next.** +Chrome 113 has `:has()`, `color-mix()` and `dialog.showModal()`, and +lacks three things this app's dependencies use: + +- **Relaxed CSS nesting** (Chrome 120): a nested rule starting with a + bare element selector is dropped. `.x { svg { ... } }` parses to + nothing; `.x { & svg { ... } }` parses. Any Web Awesome or app + stylesheet written the modern way silently loses declarations here, + and dropped declarations are exactly the failure that looks like + "rendered but wrong". +- **The Popover API** (Chrome 114). Web Awesome's popup calls + `showPopover?.()` — optional, so nothing throws — but also sets + `popover="manual"`, which on 113 is an unknown attribute doing + nothing. Every context menu, dropdown and the whole menu keyboard + model rides on that, so it is the first thing to test with a library + present. +- `light-dark()` and relative colour syntax (`rgb(from ...)`). + +**The lesson for the tier: a device is an engine, not just a screen.** +Every browser tier here runs a current Chromium or WebKit, and the phone +that will actually run this app is two years behind — so "it renders at +424x439 in Chromium" (checked, the transport is on screen) says nothing +about whether it renders on the phone. The e2e tier cannot be fixed by +resizing; the missing signal is version, and CDP against the device is +the only place to get it. + +Verified by execution: every number in the table, the four feature +probes, and that the hardware back button no longer kills the app (the +`.dev` build carries the history fix; pid survived a BACK press). +Unverified: what happened to the icons and the transport controls, which +is where this resumes. diff --git a/.planning/plans/pending/016-android-feature-parity.md b/.planning/plans/pending/016-android-feature-parity.md index 3a6ad2f..4e499d6 100644 --- a/.planning/plans/pending/016-android-feature-parity.md +++ b/.planning/plans/pending/016-android-feature-parity.md @@ -360,9 +360,14 @@ awaiting the next APK for confirmation (`NOTES.md`, same date): `webView.canGoBack()`; the frontend had never used `history`. A navigation is a history entry now, and `navStack` is gone rather than kept beside it. -- **The transport was under the gesture bar.** `targetSdk 35` is - edge-to-edge by force; `applyWindowInsets()` in `MainActivity` pads - by `systemBars | displayCutout | ime`. +- **The transport was under the gesture bar** — or so the version + number said. `applyWindowInsets()` in `MainActivity` is right and + stays, but the phone is **Android 14**, where the system still insets + the window: the fix is pre-emptive and the symptom has another cause. + Still open, along with icons that do not appear at all. The phone's + WebView is **Chrome 113**, which is the lead (no Popover API, no + relaxed CSS nesting), and `make android-inspect` / `android-eval` are + how it gets asked. The standing item is unchanged in kind: **B3 (tag writing) and the permission flow still need a device**, and so does confirming these two. diff --git a/CLAUDE.md b/CLAUDE.md index 0702d34..a71ce1a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1998,14 +1998,29 @@ still not carried and its `includes:` entry is still dropped. **Its `MainActivity` owns the safe area, because `targetSdk 35` does not leave that to the theme.** Android 15 lays every app out edge-to-edge and ignores the `statusBarColor`/`navigationBarColor` the -scaffold's theme sets, and the WebView is `match_parent` — so the page's -bottom band, which on a phone is the transport *and* the tab bar, was +scaffold's theme sets, and the WebView is `match_parent`, so the page's +bottom band — the transport and, on a phone, the tab bar — would be drawn under the gesture bar. `applyWindowInsets()` pads the container by `systemBars | displayCutout | ime` and returns the insets rather than consuming them; the window background is black to match the app's own -ramp, since that padding is what shows through. **No browser tier can -see this class of fault** — a viewport has no system bars, so the phone -specs render a shell that fits at the moment the device is clipping it. +ramp, since that padding is what shows through. It is **pre-emptive**: +the phone this was checked against is Android 14, where the system still +insets the window, and the enforcement applies to an app *running on* +15. No browser tier can see this class of fault either way — a viewport +has no system bars. + +**And a device is an engine, not just a screen.** The phone this app was +first run on renders in **Chrome 113** — two years behind every browser +any other tier uses — at a 424x439 CSS px viewport. It has `:has()`, +`color-mix()` and `dialog.showModal()`; it does **not** have relaxed CSS +nesting (Chrome 120, so a nested rule beginning with a bare element +selector is silently dropped), the Popover API (114, which Web Awesome's +popups set `popover="manual"` for), `light-dark()` or relative colour +syntax. So "it renders at that size in Chromium" is not evidence about +the phone, and resizing a spec cannot recover the missing signal. `make +android-inspect` forwards the WebView's devtools socket and `make +android-eval` asks the real page — raw CDP, because `connectOverCDP` +calls `Browser.setDownloadBehavior` and a WebView refuses it. `build/config.yml`'s `version` is the *metadata* version and is not what the app reports — `main.version` is diff --git a/Makefile b/Makefile index f341fc0..1b4defc 100644 --- a/Makefile +++ b/Makefile @@ -78,6 +78,19 @@ android-launch: ## Force-stop, clear logcat, and start the app android-logs: ## Tail logcat, filtered to the app's own tags @$(ANDROID_ENV) ./scripts/android-emulator.sh logs +# The only tier that can see the platform is the one you can look at. +android-screenshot: ## Grab the device screen (OUT=) + @$(ANDROID_ENV) ./scripts/android-emulator.sh screenshot $(OUT) + +# The page's own answer, from the engine that is really rendering it. +# Needs the debug build installed (it is a sibling id, so it does not +# disturb the release app): see scripts/android-eval.mjs. +android-inspect: ## Forward the device WebView's devtools socket + @$(ANDROID_ENV) ./scripts/android-emulator.sh inspect + +android-eval: ## Evaluate JS in the device WebView (EXPR='...') + @node ./scripts/android-eval.mjs $(if $(EXPR),'$(EXPR)',) + # "Did it start" is the wrong question — a crash-looping app starts # several times a second. This asserts the *same pid* is still there. android-smoke: ## Launch and assert the app is still alive (SECONDS=) diff --git a/build/android/app/build.gradle b/build/android/app/build.gradle index 037ceeb..1343a81 100644 --- a/build/android/app/build.gradle +++ b/build/android/app/build.gradle @@ -83,6 +83,16 @@ android { } debug { debuggable true + // Its own application id, so it installs *beside* the release + // app rather than needing an uninstall to replace it. The two + // are signed by different certificates (the release one comes + // from a keystore CI holds), and Android's remedy for a + // certificate change is an uninstall -- which takes the + // user's library with it. This is also what makes the WebView + // inspectable on a real phone: `debuggable` is what turns on + // `setWebContentsDebuggingEnabled`, and `make android-inspect` + // drives it. + applicationIdSuffix ".dev" } } diff --git a/scripts/android-emulator.sh b/scripts/android-emulator.sh index 89523a4..578528f 100755 --- a/scripts/android-emulator.sh +++ b/scripts/android-emulator.sh @@ -35,6 +35,8 @@ cd "$(dirname "$0")/.." AVD="${YJ_AVD:-yj-test}" SDK="${ANDROID_SDK_ROOT:-${ANDROID_HOME:-$HOME/Android/Sdk}}" PKG="${YJ_ANDROID_PKG:-app.yellowjacket}" +# Where `make android-inspect` forwards the WebView's devtools socket. +CDP_PORT="${YJ_ANDROID_CDP_PORT:-9222}" # **Not "$PKG/.MainActivity".** A leading-dot activity is resolved # relative to the *applicationId*, and the scaffold's activity lives in # the Java package `com.wails.app`, which is deliberately not the @@ -264,6 +266,67 @@ cmd_logs() { WailsBridge:V "$PKG":V GoLog:V AndroidRuntime:E DEBUG:V libc:F ActivityManager:I '*:S' } +# Forward the WebView's devtools socket, so the page can be asked things. +# +# Only a `debuggable` build opens that socket, and a debug build carries +# `applicationIdSuffix ".dev"` precisely so it can be installed *beside* +# the release app: the two are signed by different certificates, and +# Android's remedy for a certificate change is an uninstall, which takes +# the user's library with it. So this looks for the sibling first and the +# release id second. +# +# The socket name carries the pid, which changes on every launch -- which +# is why this resolves it rather than documenting a number. +cmd_inspect() { + need_sdk + pick_device || die "no device -- plug a phone in (USB debugging on) or run 'make android-emulator'" + + local pkg pid + pid="" + + for pkg in "$PKG.dev" "$PKG"; do + pid=$("$ADB" shell pidof "$pkg" 2>/dev/null | tr -d '\r' | awk '{print $1}') + [ -n "$pid" ] && break + done + + [ -n "$pid" ] || die "neither $PKG.dev nor $PKG is running; launch it first" + + "$ADB" forward --remove-all >/dev/null 2>&1 || true + "$ADB" forward "tcp:$CDP_PORT" "localabstract:webview_devtools_remote_$pid" >/dev/null \ + || die "adb forward failed" + + echo "android: $pkg (pid $pid) devtools on http://localhost:$CDP_PORT" + echo " make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'" + echo " (a release build has no devtools socket: build and install the debug one)" +} + +# What the phone is actually showing. +# +# This tier exists because no other one can see the platform: system +# bars, the safe area, the keyboard, an OEM's permission dialog. All of +# those are things you have to *look* at, and two of the three faults +# found so far were found by reading a picture rather than an assertion +# (`android-tier.md`). +# +# `exec-out` and not `shell`: `adb shell` runs the output through a pty +# on some platforms, which translates LF and corrupts the PNG -- for +# which the symptom is an image viewer refusing a file that downloaded +# perfectly. +cmd_screenshot() { + need_sdk + pick_device || die "no device \u2014 plug a phone in (USB debugging on) or run 'make android-emulator'" + + local out="${1:-}" + + [ -n "$out" ] || out=".dev/android-$(date +%Y%m%d-%H%M%S).png" + mkdir -p "$(dirname "$out")" + + "$ADB" exec-out screencap -p > "$out" || die "screencap failed" + [ -s "$out" ] || die "screencap produced nothing (is the screen locked?)" + + echo "android: screenshot -> $out" +} + # Start the app and assert it is *still the same process* a few seconds # later. "It started" is not the question — a crash-looping app starts # continuously. @@ -316,9 +379,11 @@ stop) cmd_stop ;; install) cmd_install ;; launch) cmd_launch ;; logs) cmd_logs ;; +screenshot) cmd_screenshot "${2:-}" ;; +inspect) cmd_inspect ;; smoke) cmd_smoke "${2:-10}" ;; *) - echo "usage: $0 {setup|start|stop|install|launch|logs|smoke [seconds]}" >&2 + echo "usage: $0 {setup|start|stop|install|launch|logs|screenshot [path]|inspect|smoke [seconds]}" >&2 exit 2 ;; esac diff --git a/scripts/android-eval.mjs b/scripts/android-eval.mjs new file mode 100644 index 0000000..0ee139b --- /dev/null +++ b/scripts/android-eval.mjs @@ -0,0 +1,97 @@ +/** + * Evaluate an expression inside the app's WebView on a real device. + * + * The device tier could only ever *look* at the app (a screenshot) or + * read what Go chose to log. This is the third thing: the page's own + * answer, from the engine that is actually rendering it — which is how + * "the icons are missing" stops being a guess about assets and becomes a + * computed style. + * + * Two facts make it work at all. A `debuggable` build calls + * `WebView.setWebContentsDebuggingEnabled(true)`, which opens an abstract + * unix socket per process (`webview_devtools_remote_`); `make + * android-inspect` forwards it to localhost. And **Playwright cannot use + * it** — `connectOverCDP` immediately calls `Browser.setDownloadBehavior`, + * which a WebView answers with "Browser context management is not + * supported", so the connection dies before the first evaluate. Raw CDP + * over Node's built-in WebSocket is a dozen lines and has no such + * opinion. + * + * Usage: node scripts/android-eval.mjs '' + * make android-eval EXPR='...' + * + * The expression is evaluated with `awaitPromise`, so an async probe is + * fine. Return a string (`JSON.stringify(...)`) for anything structured: + * `returnByValue` will not serialise a DOM node. + */ +const PORT = process.env.YJ_ANDROID_CDP_PORT ?? '9222'; +const expression = process.argv[2]; + +if (!expression) { + console.error("usage: node scripts/android-eval.mjs ''"); + process.exit(2); +} + +const endpoint = `http://localhost:${PORT}/json`; +let targets; + +try { + targets = await (await fetch(endpoint)).json(); +} catch (err) { + console.error( + `android-eval: nothing on :${PORT} (${err.message})\n` + + " run 'make android-inspect' first, and check the phone is " + + 'awake -- wireless adb drops when the screen sleeps', + ); + process.exit(1); +} + +const page = targets.find((t) => t.type === 'page'); + +if (!page) { + console.error('android-eval: no page target; is the app in the foreground?'); + process.exit(1); +} + +const ws = new WebSocket(page.webSocketDebuggerUrl); + +await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = () => reject(new Error('websocket refused')); +}); + +const answer = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('evaluate timed out')), 20_000); + + ws.onmessage = (m) => { + const msg = JSON.parse(m.data); + + if (msg.id !== 1) return; + + clearTimeout(timer); + resolve(msg.result); + }; + + ws.send( + JSON.stringify({ + id: 1, + method: 'Runtime.evaluate', + params: { expression, awaitPromise: true, returnByValue: true }, + }), + ); +}); + +ws.close(); + +if (answer.exceptionDetails) { + console.error( + 'android-eval: threw:', + answer.exceptionDetails.exception?.description ?? + answer.exceptionDetails.text, + ); + process.exit(1); +} + +const value = answer.result?.value; + +console.log(typeof value === 'string' ? value : JSON.stringify(value, null, 2));