The app sometimes drew under the status and gesture bars and sometimes between them. Cause: Capacitor 8 ships a SystemBars plugin that already handles insets, from a listener on the decor view, and my MainActivity had installed another one on the WebView. Different views, so they stacked rather than replaced each other and the result depended on which fired last. SystemBars switches on the viewport meta tag. With viewport-fit=cover it passes insets through once onPageCommitVisible has checked the tag, decor padding drops to 0 and the WebView grows 735 -> 820, leaving the page to place itself from injected CSS vars. Without it the plugin pads the decor view and the WebView is laid out between the bars already. So sync-www.sh now strips viewport-fit=cover from the generated www/ (patch 5), MainActivity is an empty BridgeActivity, native.css and native.js no longer touch insets, and bar style moves to capacitor.config.json. No native->web handshake remains, so there is nothing to race and no launch-time layout jump. Verified identical across 3 cold launches, 2 resumes and an in-place reload: h=735 padTop=8px overflowBy=0, panel 485px, pad rows 44.8px. Costs no space: 820-45-114 and 735-8-66 both leave 661px. The bars take the same room either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
13 KiB
Markdown
260 lines
13 KiB
Markdown
# SP-404MK2 layers
|
||
|
||
A combo trainer for the Roland SP-404MK2, wrapped as a sideloaded Android APK.
|
||
|
||
**Phase 1 (current):** the original single-file web app, wrapped in Capacitor as-is.
|
||
**Phase 2 (next):** port to React + TypeScript via Vite — data and pure logic with
|
||
tests first, then UI, then multitouch input.
|
||
|
||
## Layout
|
||
|
||
```
|
||
reference/ The original working web app. FROZEN — this is the spec.
|
||
Never edit it. Behaviour, data and styling are all defined here.
|
||
native/ Native-only additions (native.css, native.js). Not in reference.
|
||
scripts/ install-android-toolchain.sh, sync-www.sh
|
||
www/ GENERATED by sync-www.sh. Never edit by hand, never committed.
|
||
android/ Generated by `npx cap add android`. A few files are hand-edited
|
||
(see "Hand-edited native files" below).
|
||
```
|
||
|
||
## Commands
|
||
|
||
| Command | What it does |
|
||
|---|---|
|
||
| `npm run sync:www` | Rebuild `www/` from `reference/` + `native/` |
|
||
| `npm run sync` | The above, then `npx cap sync android` |
|
||
| `npm run apk` | Sync, then `./gradlew assembleDebug` |
|
||
| `npm run install:apk` | `adb install -r` the debug APK |
|
||
| `npm run run:android` | Build + install in one go — the usual loop |
|
||
| `npm run logcat` | Tail Capacitor + web console output from the device |
|
||
|
||
APK lands at `android/app/build/outputs/apk/debug/app-debug.apk`.
|
||
|
||
The Gradle build needs these in the environment (persisted to `~/.zshrc` by the
|
||
install script):
|
||
|
||
```
|
||
JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
|
||
ANDROID_HOME=$HOME/Android/Sdk
|
||
```
|
||
|
||
## Driving the device
|
||
|
||
**Launch through `scripts/device.sh`, never a bare `am start`.** TCL denies the
|
||
WebView renderer in the window just after `adb install -r`, and the app then
|
||
comes up blank with no crash, no JS error and an empty `document.title`. The
|
||
failure is sticky for the life of the process, so the app must be force-stopped
|
||
and started again — which is exactly what the helper retries.
|
||
|
||
Keep the phone unlocked while working. That is not what causes the denial (see
|
||
Device notes), but nothing on screen can be verified through a lockscreen and
|
||
`input keyevent` will not reach the app.
|
||
|
||
`scripts/device.sh` handles this so it cannot be forgotten:
|
||
|
||
| Command | What it does |
|
||
|---|---|
|
||
| `npm run device:keep-awake` | Stay awake while charging. Do this at the start of a session. |
|
||
| `npm run device:launch` | Check awake/unlocked, force-stop, start, wait for the page to actually parse |
|
||
| `npm run device:awake` | Just the check — prints a loud warning if locked |
|
||
| `npm run device:sleep-normal` | Revert the stay-awake setting when finished |
|
||
|
||
`device:launch` waits for the page, not merely for the activity: the activity
|
||
starts fine even when the renderer was denied. On failure it prints the
|
||
`AppBootManager` denial lines rather than leaving a blank window to puzzle over.
|
||
|
||
It also reads the pid *after* starting the app. `adb install -r` kills the
|
||
running process, so a pid captured earlier points at a dead process and the
|
||
devtools socket lookup fails while the app itself is healthy — that produced one
|
||
false "page never loaded" during Phase 1.
|
||
|
||
To attach Chrome DevTools by hand: `chrome://inspect`, or
|
||
`adb forward tcp:9333 localabstract:webview_devtools_remote_$(adb shell pidof dev.ncode.sp404layers)`
|
||
and open `http://127.0.0.1:9333/json`.
|
||
|
||
## Toolchain (verified, do not bump blind)
|
||
|
||
Pinned against Capacitor 8.5.2's `capacitor/build.gradle`:
|
||
`compileSdk 36`, `targetSdk 36`, `minSdk 24`, Java 21.
|
||
|
||
- JDK **21** — required by Capacitor 8; `openjdk-21-jdk` from apt
|
||
- cmdline-tools **15859902**
|
||
- build-tools **36.1.0** — pairs with compileSdk 36
|
||
- platforms **android-35**, **android-36**
|
||
|
||
`sdkmanager` prints a deprecation warning pointing at the newer `android` CLI.
|
||
It still works, and it writes the `licenses/` directory that Gradle checks. The
|
||
replacement downloads itself on first run and gates on an interactive ToS, which
|
||
is worse in an idempotent install script. Revisit if it actually breaks.
|
||
|
||
## Decisions
|
||
|
||
**`reference/` is never edited.** It is the Phase 2 spec, so it has to stay
|
||
byte-for-byte comparable. Everything native-specific is either an additive file
|
||
or one of three removals applied by `sync-www.sh`, which asserts each anchor
|
||
exists and fails loudly if the reference ever changes shape.
|
||
|
||
The three removals:
|
||
1. **Service worker registration** — would cache `index.html` and serve a stale
|
||
app after an APK update. Assets are already local, so it buys nothing.
|
||
2. **Web manifest link** — the APK's own manifest governs the app.
|
||
3. Nothing else. `native.css` and `native.js` are *injected*, not substituted.
|
||
|
||
**Haptics needs no reference change.** `buzz()` is a `const` and cannot be
|
||
reassigned, but it calls `navigator.vibrate`, which can be overridden. `native.js`
|
||
redefines that to dispatch to `@capacitor/haptics`: scalar → `impact`, array →
|
||
`notification({type:"SUCCESS"})`, `>15` → `notification({type:"ERROR"})`.
|
||
|
||
**Storage mirrors rather than migrates.** `localStorage` stays the synchronous
|
||
source of truth so `load()`/`save()` keep their signatures. `save()` is wrapped
|
||
to mirror into `@capacitor/preferences` (SharedPreferences), which survives a
|
||
"clear app data" wipe. On boot, if `localStorage` is empty but Preferences is
|
||
not, it restores and re-renders. A full async port was rejected as a refactor of
|
||
code about to be rewritten in React anyway.
|
||
|
||
**Back button** is owned by `native.js`: sheet open → close it; otherwise not on
|
||
Explore → go to Explore; otherwise exit. Registering a listener suppresses
|
||
Capacitor's default history navigation.
|
||
|
||
**System bars: let Capacitor do it, by dropping `viewport-fit=cover`.** The app
|
||
renders between the status and gesture bars, always.
|
||
|
||
Capacitor 8 ships a `SystemBars` plugin that owns inset handling from a listener
|
||
on the **decor view**. Its behaviour hinges on the viewport meta tag:
|
||
|
||
- **with `viewport-fit=cover`** (what `reference/` has), `shouldPassthroughInsets`
|
||
becomes true once `onPageCommitVisible` has checked the tag and called
|
||
`requestApplyInsets()`. Decor padding drops to 0, the WebView grows from 735 to
|
||
820, and the page must place itself from injected CSS variables.
|
||
- **without it**, the plugin pads the decor view instead. The WebView is 735px
|
||
and already sits between the bars; `env(safe-area-inset-*)` and the
|
||
reference's `--sab` are 0, and `.bnav` and `.sheet` are correct as written.
|
||
|
||
So `sync-www.sh` strips `viewport-fit=cover` from the generated `www/` (patch 5).
|
||
No native→web handshake exists, so there is nothing to race and no launch-time
|
||
layout jump. Verified identical across cold launch, resume and in-place reload:
|
||
`h=735 padTop=8px overflowBy=0`.
|
||
|
||
**This costs no space.** 820 − 45 − 114 and 735 − 8 − 66 both leave 661px, because
|
||
the bars take the same room whether the WebView is padded or draws underneath.
|
||
Reclaiming that 85px would mean *hiding* the bars, which is a different choice.
|
||
|
||
An earlier version did the opposite: `MainActivity` installed its own
|
||
`OnApplyWindowInsetsListener` on the WebView and pushed `--ins-t/r/b/l` through
|
||
`window.__applyInsets()`, with a 25 × 200ms retry. That was written without
|
||
realising Capacitor already had a plugin for this. The two listeners were on
|
||
different views, so they stacked rather than replaced each other, and the app
|
||
drew under the bars at random depending on which fired last. `MainActivity` is
|
||
now empty and bar styling is configured in `capacitor.config.json`
|
||
(`SystemBars.style: DARK`).
|
||
|
||
**No community plugins.** Only first-party: `@capacitor/core`, `/cli`,
|
||
`/android`, `/haptics`, `/preferences`, `/app`.
|
||
|
||
**Debug signing.** The debug keystore is stable per-machine, so Phase 2's APK
|
||
installs over Phase 1's without an uninstall and drill progress survives.
|
||
`appId` is `dev.ncode.sp404layers` and must not change for the same reason.
|
||
|
||
## Phase 2 must carry these UI changes
|
||
|
||
Phase 1 fixes a strip ergonomics problem in `native/native.{css,js}` rather than
|
||
in `reference/`, which stays frozen. The React port must reproduce these, since
|
||
they will not come along with the reference:
|
||
|
||
1. **The two chip rows sit flush on phones.** The reference spaces them with
|
||
`#strip .chips + .chips { margin-top: 6px }`, but the `.lbl` heading between
|
||
them is `display:none` on phones and still in the DOM, so the adjacent-sibling
|
||
selector never matches. Measured gap: 0px. Both selected chips are also
|
||
light-filled and left-aligned, so they visually merge into one shape.
|
||
2. **Both chip rows collapse into two pickers side by side on one 44px row.**
|
||
Only one layer is ever active, so neither the mode nor the layer needs a
|
||
scrolling row of its own. Each opens the existing sheet. The mode sheet shows
|
||
`CTXHINT`, which the phone layout otherwise never displays; the layer sheet
|
||
shows each layer's function count.
|
||
|
||
This matters for the panel, not just for tidiness: `.pads` uses
|
||
`minmax(34px, 1fr)` rows and on this device it sits exactly at that 34px
|
||
floor, so the SP-404 illustration clips at the bottom if anything takes more
|
||
room. Two rows cost 96px; one costs 44px, and the 52px goes to the panel.
|
||
|
||
The layer list is read out of the reference's own chips at render time rather
|
||
than recomputed, so the set, ordering and counts cannot drift from what
|
||
`renderExplore()` decided. In React this becomes shared derived state
|
||
instead.
|
||
3. **Chip targets go from 34.9px to 44px.** They scroll horizontally, so an
|
||
undersized target turns a mis-hit into a scroll instead of a tap.
|
||
4. **Overflowing rows get a right-edge fade.** Scrollbars are hidden, and both
|
||
rows overflow badly (703px and 952px of content in a 360px viewport) with
|
||
nothing indicating more content. The fade is applied only to rows that
|
||
actually overflow.
|
||
|
||
In React this should be real component state, not a post-render DOM patch:
|
||
Phase 1 has to re-apply the collapse after every `renderExplore()` because that
|
||
function rewrites `#strip` wholesale.
|
||
|
||
## Hand-edited native files
|
||
|
||
`cap sync` does not regenerate these — keep them when recreating `android/`:
|
||
|
||
- `android/app/src/main/AndroidManifest.xml` — `android:screenOrientation="portrait"`
|
||
|
||
That is the only one. `MainActivity.java` is deliberately an empty
|
||
`BridgeActivity`: everything it used to do is either configured in
|
||
`capacitor.config.json` or handled by Capacitor's own `SystemBars` plugin. If
|
||
you find yourself adding an inset listener there, read the system-bars decision
|
||
above first — that path has already been tried and reverted.
|
||
|
||
## Device notes (TCL 50 PRO NXTPAPER 5G, Android 16 / API 36)
|
||
|
||
**adb needs a udev rule.** The phone is USB `1bbb:0c01`. Ubuntu's
|
||
`android-sdk-platform-tools-common` matches vendor *and* product, and lists only
|
||
older TCL products, so nothing matched and the device node stayed `root:root`.
|
||
Fixed with a vendor-wide rule (product IDs change with USB mode):
|
||
|
||
```
|
||
echo 'SUBSYSTEM=="usb", ATTR{idVendor}=="1bbb", MODE="0660", GROUP="plugdev", TAG+="uaccess"' \
|
||
| sudo tee /etc/udev/rules.d/51-android-tcl.rules
|
||
sudo udevadm control --reload-rules && sudo udevadm trigger
|
||
```
|
||
|
||
It lives in `/etc/udev` so apt upgrades don't overwrite it. The `uaccess` tag
|
||
grants the logged-in session access, so no logout is needed.
|
||
|
||
**A blank grey window means the renderer was denied — relaunch.** TCL's
|
||
`AppBootManagerService` sometimes refuses to let the app bind the WebView's
|
||
sandboxed renderer process:
|
||
|
||
```
|
||
W/AppBootManager: Type[TYPE_SERVICE];Result[false];
|
||
Target[org.chromium.content.app.SandboxedProcessService0];
|
||
Caller[dev.ncode.sp404layers];Reason[screen_on & caller care]
|
||
E/cr_ChildProcessConn: Failed to establish the service connection.
|
||
```
|
||
|
||
With no renderer the page never parses, so there is no crash, no JS error, an
|
||
empty `document.title`, and DevTools `Runtime.evaluate` hangs. The failure is
|
||
sticky for the life of the process — reopening from Recents keeps the grey
|
||
window. Only `am force-stop` plus a fresh start clears it.
|
||
|
||
**The trigger is the window just after `adb install -r` replaces the package.**
|
||
Every observed failure followed an install; an immediate relaunch with no
|
||
install has always succeeded on the first try. Do not trust the `Reason` string:
|
||
it reports the current screen state, and both `screen_off` and `screen_on` have
|
||
been seen denying, so screen state is not the cause. An earlier version of this
|
||
file claimed it was — that was wrong.
|
||
|
||
`scripts/device.sh launch` therefore retries the whole force-stop/start cycle up
|
||
to 3 times. This is an OEM policy, not an app bug, and it only bites when
|
||
launching over adb — tapping the icon is fine.
|
||
|
||
## Gotchas
|
||
|
||
- The reference's multitouch relies on `touch-action:none` on `.unit` plus
|
||
per-`pointerId` tracking in a `Map`. This is the most delicate part to port in
|
||
Phase 2 — read `exDown`/`exUp`/`drDown`/`drUp` before touching it.
|
||
- `store`, `st` and `SK` are `const`/`let` at classic-script top level: reachable
|
||
by bare name from `native.js`, but absent from `window`. `save`/`render`/
|
||
`closeSheet` are function declarations, so they *are* on `window` and can be
|
||
wrapped.
|