Files
404-Layers/CLAUDE.md
ncode 6445114203 Document TCL device quirks found during on-device verification
Two device-level issues cost real debugging time and neither is an app bug:

- adb could not open the phone: Ubuntu's udev rules match vendor AND
  product, and list only older TCL products, so nothing matched 1bbb:0c01.
- A blank grey window: TCL's AppBootManagerService refuses to let the app
  bind the WebView renderer service while the display is dozing, so the
  page never parses. No crash, no JS error, empty document.title, and the
  failure is sticky for the process lifetime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:46:34 +03:00

153 lines
6.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
## 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.
## 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.