# 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 **The screen must be awake and unlocked before launching, every time.** While the display dozes, TCL denies the WebView renderer and the app 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 waking the phone afterwards does not repair it — the app has to be force-stopped and started again. `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. **Edge-to-edge is handled natively, not avoided.** Android 16 is API 36 and Capacitor 8 targets SDK 36, which is exactly the combination where `windowOptOutEdgeToEdgeEnforcement` stops working. An Android WebView never populates `env(safe-area-inset-*)`, so `MainActivity` installs an `OnApplyWindowInsetsListener`, converts px → dp, and pushes the values into `--ins-t/r/b/l` via `window.__applyInsets()`. `native.css` re-points the reference's `--sab` at `--ins-b`, so `.bnav` and `.sheet` need no changes. The push retries on a bounded schedule (25 × 200ms) because insets typically arrive before the page has run any JS. **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. **The 7 context chips collapse into one 44px control** that opens the existing sheet, freeing a row for the panel. The sheet rows show `CTXHINT`, which the phone layout otherwise never displays. 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/java/dev/ncode/sp404layers/MainActivity.java` — inset bridge - `android/app/src/main/AndroidManifest.xml` — `android:screenOrientation="portrait"` ## 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 screen was off when the app started.** TCL's `AppBootManagerService` refuses to let the app bind the WebView's sandboxed renderer process while the display is dozing: ``` W/AppBootManager: Type[TYPE_SERVICE];Result[false]; Target[org.chromium.content.app.SandboxedProcessService0]; Caller[dev.ncode.sp404layers];Reason[screen_off & 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. Wake and unlock the phone first, then `am force-stop` and relaunch. This is an OEM policy, not an app bug, and it only bites when launching over adb. ## 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.