Wraps reference/sp404-layers as a sideloadable APK without editing the reference, which stays the frozen spec for the Phase 2 React port. - sync-www.sh builds www/ from reference/ + native/, asserting each patch anchor so a reference change fails loudly rather than shipping broken - drops the service worker (would serve a stale app after an APK update) - haptics via a navigator.vibrate override, so buzz() needs no change - localStorage mirrored into Preferences; sync load()/save() untouched - hardware back: sheet -> Explore tab -> exit - edge-to-edge handled in MainActivity: Android 16 + targetSdk 36 removes the opt-out, and a WebView never fills env(safe-area-inset-*), so insets are pushed into CSS custom properties instead Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.4 KiB
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-jdkfrom 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:
- Service worker registration — would cache
index.htmland serve a stale app after an APK update. Assets are already local, so it buys nothing. - Web manifest link — the APK's own manifest governs the app.
- Nothing else.
native.cssandnative.jsare 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 bridgeandroid/app/src/main/AndroidManifest.xml—android:screenOrientation="portrait"
Gotchas
- The reference's multitouch relies on
touch-action:noneon.unitplus per-pointerIdtracking in aMap. This is the most delicate part to port in Phase 2 — readexDown/exUp/drDown/drUpbefore touching it. store,standSKareconst/letat classic-script top level: reachable by bare name fromnative.js, but absent fromwindow.save/render/closeSheetare function declarations, so they are onwindowand can be wrapped.