diff --git a/.gitignore b/.gitignore index 150927f..23d25ab 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ local.properties /SchwertUndMagieOnPebbleCompanionApp/app/src/main/jni/vice-libs /SchwertUndMagieOnPebbleWatchApp/build + +# Release signing — never commit keystores or their credentials. +# See docs/publish.md. +*.jks +*.keystore +keystore.properties diff --git a/CLAUDE.md b/CLAUDE.md index 0673df2..31681cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,9 +101,13 @@ The NDK must be installed: **Android Studio → SDK Manager → SDK Tools → ND After the first successful build, subsequent builds skip the VICE compilation step entirely (outputs are up-to-date). -**C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) all ship inside the VICE 3.8 -tarball at `res/vice-3.8.tar.gz` and are bundled in the APK under `app/src/main/assets/`. -They are copied automatically to the external files dir on first launch — no user action needed. +**C64 ROM files** (`kernal`, `basic`, `chargen`, `1541`) are copyrighted and are +**not** bundled in the APK. On first launch, `MainActivity` checks the app's +external files dir for these four files; if any are missing, it shows a blocking +dialog that lets the user import their own legally-obtained ROM dump via the +system file picker (see `docs/publish.md` §0). `res/extract_roms.sh` can pull +the ROMs out of `res/vice-3.8.tar.gz` into `res/roms/` for local sideloading +during development, but nothing in the Gradle build copies them into the APK. ### Key Kotlin/C files diff --git a/SchwertUndMagieOnPebbleCompanionApp/.gitignore b/SchwertUndMagieOnPebbleCompanionApp/.gitignore index aa724b7..a8e99dc 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/.gitignore +++ b/SchwertUndMagieOnPebbleCompanionApp/.gitignore @@ -13,3 +13,6 @@ .externalNativeBuild .cxx local.properties + +# ROM files extracted from the VICE tarball by res/extract_roms.sh +/res/roms diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/.gitignore b/SchwertUndMagieOnPebbleCompanionApp/app/.gitignore index 4218c1e..af81c1f 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/.gitignore +++ b/SchwertUndMagieOnPebbleCompanionApp/app/.gitignore @@ -1,7 +1,8 @@ /build -# ROM files extracted from the VICE tarball at build time (see extractViceRoms task) +# Copyrighted Commodore ROMs are never bundled — users import their own at +# runtime (MainActivity.showRomImportDialog). Ignored here as a safety net. /src/main/assets/kernal /src/main/assets/basic /src/main/assets/chargen -/src/main/assets/1541 \ No newline at end of file +/src/main/assets/1541 diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts b/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts index 8ec2505..f5a69fd 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts +++ b/SchwertUndMagieOnPebbleCompanionApp/app/build.gradle.kts @@ -130,47 +130,12 @@ tasks.whenTaskAdded { } } -// --------------------------------------------------------------------------- -// ROM extraction task -// -// Extracts C64 and drive ROMs from the bundled VICE tarball into the Android -// assets directory so they are packaged into the APK automatically. -// Output files are gitignored — the tarball is the single source of truth. -// --------------------------------------------------------------------------- - -val assetsDir = layout.projectDirectory.dir("src/main/assets") - -tasks.register("extractViceRoms") { - group = "build" - description = "Extract C64 and 1541 ROMs from the VICE tarball into assets/" - - from(tarTree(viceTarball.asFile)) { - include("vice-3.8/data/C64/kernal-901227-03.bin") - include("vice-3.8/data/C64/basic-901226-01.bin") - include("vice-3.8/data/C64/chargen-901225-01.bin") - include("vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin") - eachFile { - // Flatten the tarball directory structure: place each ROM directly - // in assets/ with its short name rather than the versioned filename. - relativePath = RelativePath(true, when (name) { - "kernal-901227-03.bin" -> "kernal" - "basic-901226-01.bin" -> "basic" - "chargen-901225-01.bin" -> "chargen" - "dos1541-325302-01+901229-05.bin" -> "1541" - else -> name - }) - } - includeEmptyDirs = false - } - into(assetsDir) -} - -// Run extractViceRoms before assets are merged into the APK. -tasks.whenTaskAdded { - if (name.startsWith("merge") && name.endsWith("Assets")) { - dependsOn("extractViceRoms") - } -} +// Note: C64/1541 ROMs are intentionally NOT bundled into the APK — they are +// copyrighted Commodore firmware. The app prompts the user to import their +// own ROM dump at runtime instead (see MainActivity.showRomImportDialog()). +// res/extract_roms.sh remains available for extracting ROMs from the VICE +// tarball locally (e.g. to sideload onto a test device), but nothing in this +// build copies them into assets/ or the APK. dependencies { implementation(libs.androidx.activity.ktx) diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt index 8772535..5f72ec3 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/java/de/ladkau/schwertundmagieonpebblecompanionapp/MainActivity.kt @@ -31,9 +31,15 @@ import androidx.drawerlayout.widget.DrawerLayout import fi.iki.elonen.NanoHTTPD import org.json.JSONObject import java.io.File +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.URL import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.zip.GZIPInputStream private const val PORT = 8888 private const val TAG = "SuM" @@ -235,7 +241,14 @@ class MainActivity : AppCompatActivity() { private fun initEmulator() { val assetDir = getExternalFilesDir(null) ?: filesDir assetDir.mkdirs() - copyBundledRoms(assetDir) + if (missingRoms(assetDir).isNotEmpty()) { + showRomImportDialog(assetDir) + return + } + startEmulator(assetDir) + } + + private fun startEmulator(assetDir: File) { val romDir = assetDir.absolutePath val ok = engine.initEmulator(romDir) Log.d(TAG, "initEmulator=$ok romDir=$romDir") @@ -262,22 +275,213 @@ class MainActivity : AppCompatActivity() { }) } - // ---- bundled ROM extraction --------------------------------------------- + // ---- ROM import ----------------------------------------------------- - private val bundledRoms = listOf("kernal", "basic", "chargen", "1541") + // The companion app does not ship Commodore ROMs (they're copyrighted) — + // the user must supply their own dump on first run. See docs/publish.md §0. + private val requiredRoms = listOf("kernal", "basic", "chargen", "1541") - private fun copyBundledRoms(destDir: File) { - for (name in bundledRoms) { - val dest = File(destDir, name) - if (dest.exists()) continue - try { - assets.open(name).use { input -> - dest.outputStream().use { output -> input.copyTo(output) } + private fun missingRoms(dir: File): List = + requiredRoms.filter { !File(dir, it).exists() } + + // Identifies which canonical ROM name a picked file corresponds to, based + // on the versioned filenames VICE ships (e.g. "kernal-901227-03.bin") as + // well as plain names a user might have renamed a dump to. + private fun romNameForFile(fileName: String): String? { + val lower = fileName.lowercase() + return when { + "kernal" in lower -> "kernal" + "chargen" in lower -> "chargen" + "basic" in lower -> "basic" + "1541" in lower -> "1541" + else -> null + } + } + + private val romPickerLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val assetDir = getExternalFilesDir(null) ?: filesDir + if (result.resultCode == Activity.RESULT_OK) { + val uris = mutableListOf() + result.data?.clipData?.let { clip -> + for (i in 0 until clip.itemCount) uris.add(clip.getItemAt(i).uri) + } ?: result.data?.data?.let { uris.add(it) } + + for (uri in uris) { + val name = displayNameForUri(uri) ?: continue + val romName = romNameForFile(name) ?: continue + contentResolver.openInputStream(uri)?.use { input -> + File(assetDir, romName).outputStream().use { output -> input.copyTo(output) } + } + appendLog("Imported ROM: $romName") } - Log.d(TAG, "Copied bundled ROM: $name") - } catch (e: Exception) { - Log.e(TAG, "Failed to copy bundled ROM $name", e) } + val stillMissing = missingRoms(assetDir) + if (stillMissing.isEmpty()) { + startEmulator(assetDir) + } else { + Toast.makeText( + this, getString(R.string.roms_still_missing, stillMissing.joinToString()), + Toast.LENGTH_LONG + ).show() + showRomImportDialog(assetDir) + } + } + + private fun showRomImportDialog(dir: File) { + AlertDialog.Builder(this) + .setTitle(R.string.roms_missing_title) + .setMessage(getString(R.string.roms_missing_msg, missingRoms(dir).joinToString())) + .setCancelable(false) + .setPositiveButton(R.string.roms_import_button) { _, _ -> + launchPicker(romPickerLauncher, getString(R.string.rom_picker_title), multiSelect = true) + } + .setNeutralButton(R.string.roms_download_button) { _, _ -> downloadRoms(dir) } + .show() + } + + // The same VICE 3.8 source tarball already bundled at res/vice-3.8.tar.gz + // (used to cross-compile VICE itself), fetched from VICE's own official + // GitHub release rather than shipped in the APK. The four ROMs are + // extracted from it client-side — see docs/publish.md §0 for why this is + // not equivalent to bundling them in the app. + private val viceTarballUrl = + "https://github.com/VICE-Team/svn-mirror/releases/download/3.8.0/vice-3.8.tar.gz" + + private fun downloadRoms(destDir: File) { + val dp = { v: Int -> (v * resources.displayMetrics.density).toInt() } + val progressBar = android.widget.ProgressBar( + this, null, android.R.attr.progressBarStyleHorizontal + ).apply { isIndeterminate = true } + val statusTv = TextView(this).apply { + text = getString(R.string.roms_downloading) + textSize = 12f + setTextColor(Color.parseColor("#CCCCCC")) + setPadding(0, dp(8), 0, 0) + } + val content = android.widget.LinearLayout(this).apply { + orientation = android.widget.LinearLayout.VERTICAL + setPadding(dp(20), dp(16), dp(20), dp(8)) + setBackgroundColor(Color.parseColor("#1A1A2E")) + addView(progressBar) + addView(statusTv) + } + val progressDialog = AlertDialog.Builder(this) + .setTitle(R.string.roms_download_button) + .setView(content) + .setCancelable(false) + .show() + + Thread { + try { + val conn = (URL(viceTarballUrl).openConnection() as HttpURLConnection).apply { + connectTimeout = 15000 + readTimeout = 30000 + instanceFollowRedirects = true + } + try { + conn.connect() + if (conn.responseCode != HttpURLConnection.HTTP_OK) { + throw IOException("HTTP ${conn.responseCode}") + } + val total = conn.contentLengthLong + var lastPercent = -1 + val countingStream = CountingInputStream(conn.inputStream) { downloaded -> + if (total > 0) { + val percent = ((downloaded * 100) / total).toInt() + if (percent != lastPercent) { + lastPercent = percent + mainHandler.post { + progressBar.isIndeterminate = false + progressBar.progress = percent + statusTv.text = getString(R.string.roms_downloading_progress, percent) + } + } + } + } + extractRomsFromTarGz(countingStream, destDir) + } finally { + conn.disconnect() + } + mainHandler.post { + progressDialog.dismiss() + val stillMissing = missingRoms(destDir) + if (stillMissing.isEmpty()) { + appendLog("ROM download complete") + startEmulator(destDir) + } else { + appendLog("ROM download incomplete: ${stillMissing.joinToString()}") + Toast.makeText( + this, getString(R.string.roms_still_missing, stillMissing.joinToString()), + Toast.LENGTH_LONG + ).show() + showRomImportDialog(destDir) + } + } + } catch (e: Exception) { + Log.e(TAG, "ROM download failed", e) + mainHandler.post { + progressDialog.dismiss() + appendLog("ROM download failed: ${e.message}") + Toast.makeText(this, R.string.roms_download_failed, Toast.LENGTH_LONG).show() + showRomImportDialog(destDir) + } + } + }.start() + } + + // Minimal USTAR reader — pulls just the four named ROM entries out of the + // gzipped VICE source tarball without needing a tar library dependency. + private fun extractRomsFromTarGz(rawInput: InputStream, destDir: File) { + val wantedEntries = mapOf( + "vice-3.8/data/C64/kernal-901227-03.bin" to "kernal", + "vice-3.8/data/C64/basic-901226-01.bin" to "basic", + "vice-3.8/data/C64/chargen-901225-01.bin" to "chargen", + "vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" to "1541", + ) + GZIPInputStream(rawInput).use { gz -> + val header = ByteArray(512) + while (true) { + var read = 0 + while (read < 512) { + val n = gz.read(header, read, 512 - read) + if (n < 0) return + read += n + } + val name = String(header, 0, 100, Charsets.US_ASCII).trimEnd('\u0000', ' ') + val sizeField = String(header, 124, 12, Charsets.US_ASCII).trim('\u0000', ' ') + val size = if (sizeField.isEmpty()) 0L else sizeField.toLong(8) + + val destName = wantedEntries[name] + if (destName != null) { + File(destDir, destName).outputStream().use { out -> copyExactly(gz, out, size) } + } else { + skipExactly(gz, size) + } + val remainder = size % 512 + if (remainder != 0L) skipExactly(gz, 512 - remainder) + } + } + } + + private fun copyExactly(input: InputStream, output: OutputStream, size: Long) { + val buf = ByteArray(8192) + var remaining = size + while (remaining > 0) { + val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt()) + if (n < 0) break + output.write(buf, 0, n) + remaining -= n + } + } + + private fun skipExactly(input: InputStream, size: Long) { + val buf = ByteArray(8192) + var remaining = size + while (remaining > 0) { + val n = input.read(buf, 0, minOf(buf.size.toLong(), remaining).toInt()) + if (n < 0) break + remaining -= n } } @@ -890,3 +1094,25 @@ class CompanionServer( /** One hero's save data read from a D64 PRG sector. [values] is mutable for in-place editing. */ private class HeroPrg(val name: String, val sectorOff: Int, val values: IntArray) + +/** Reports cumulative bytes read via [onProgress] as the wrapped stream is consumed. */ +private class CountingInputStream( + private val wrapped: InputStream, + private val onProgress: (Long) -> Unit +) : InputStream() { + private var count = 0L + + override fun read(): Int { + val b = wrapped.read() + if (b >= 0) { count++; onProgress(count) } + return b + } + + override fun read(b: ByteArray, off: Int, len: Int): Int { + val n = wrapped.read(b, off, len) + if (n > 0) { count += n; onProgress(count) } + return n + } + + override fun close() = wrapped.close() +} diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/values-de/strings.xml b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/values-de/strings.xml index b8a1f1d..600ed1f 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/values-de/strings.xml +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/values-de/strings.xml @@ -17,6 +17,17 @@ SCHWUM-Disketten auswählen + + C64-ROMs erforderlich + Diese App enthält keine Commodore-ROM-Dateien (sie sind urheberrechtlich geschützt). Bitte eigenen, legal erworbenen ROM-Dump bereitstellen. Fehlend: %1$s + ROMs importieren + kernal-, basic-, chargen-, 1541-ROM-Dateien auswählen + Noch fehlend: %1$s + ROMs herunterladen + ROM-Download fehlgeschlagen — Netzwerkverbindung prüfen + Wird heruntergeladen… + Wird heruntergeladen… %1$d%% + %1$s zuerst laden — %2$s setzt dort fort diff --git a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/values/strings.xml b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/values/strings.xml index 7258753..d809e19 100644 --- a/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/values/strings.xml +++ b/SchwertUndMagieOnPebbleCompanionApp/app/src/main/res/values/strings.xml @@ -19,6 +19,17 @@ Select SCHWUM disk images + + C64 ROMs required + This app does not include Commodore ROM files (they\'re copyrighted). Please supply your own legally-obtained dump. Missing: %1$s + Import ROMs + Select kernal, basic, chargen, 1541 ROM files + Still missing: %1$s + Download ROMs + ROM download failed — check your network connection + Downloading… + Downloading… %1$d%% + Load %1$s first — %2$s continues from where it left off diff --git a/SchwertUndMagieOnPebbleCompanionApp/res/extract_roms.sh b/SchwertUndMagieOnPebbleCompanionApp/res/extract_roms.sh new file mode 100755 index 0000000..c81919e --- /dev/null +++ b/SchwertUndMagieOnPebbleCompanionApp/res/extract_roms.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Extracts the C64 and 1541 ROMs needed by vice_jni.c from vice-3.8.tar.gz. +# +# This is a manual/standalone equivalent of the `extractViceRoms` Gradle task +# in app/build.gradle.kts — same source paths, same renaming. Useful for +# inspecting the ROMs outside a full Gradle build (e.g. for the "bring your +# own ROM" flow described in docs/publish.md §0). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARBALL="$SCRIPT_DIR/vice-3.8.tar.gz" +DEST="${1:-$SCRIPT_DIR/roms}" + +if [[ ! -f "$TARBALL" ]]; then + echo "error: $TARBALL not found" >&2 + exit 1 +fi + +mkdir -p "$DEST" +WORK_DIR="$(mktemp -d)" +trap 'rm -rf "$WORK_DIR"' EXIT + +tar -xzf "$TARBALL" -C "$WORK_DIR" \ + vice-3.8/data/C64/kernal-901227-03.bin \ + vice-3.8/data/C64/basic-901226-01.bin \ + vice-3.8/data/C64/chargen-901225-01.bin \ + "vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" + +cp "$WORK_DIR/vice-3.8/data/C64/kernal-901227-03.bin" "$DEST/kernal" +cp "$WORK_DIR/vice-3.8/data/C64/basic-901226-01.bin" "$DEST/basic" +cp "$WORK_DIR/vice-3.8/data/C64/chargen-901225-01.bin" "$DEST/chargen" +cp "$WORK_DIR/vice-3.8/data/DRIVES/dos1541-325302-01+901229-05.bin" "$DEST/1541" + +echo "Extracted kernal, basic, chargen, 1541 to $DEST" diff --git a/architecture.md b/docs/architecture.md similarity index 64% rename from architecture.md rename to docs/architecture.md index 917e20d..61d530b 100644 --- a/architecture.md +++ b/docs/architecture.md @@ -10,29 +10,7 @@ Three processes cooperate across two devices. **Core for Pebble** is a separate app on the phone (not part of this repo) that bridges Bluetooth AppMessage traffic to a JS runtime; our companion app talks to it only via loopback HTTP. -```mermaid -graph LR - subgraph Watch["Pebble Time 2 (watch)"] - WatchC["Watch app (C)
splash / main / wheel windows"] - end - - subgraph Phone["Android phone"] - CFP["Core for Pebble
(PebbleKit JS runtime, separate app)"] - - subgraph Companion["Companion app process"] - HTTP["NanoHTTPD server :8888"] - UI["MainActivity / UI
C64DisplayView, C64KeyboardView"] - JNI["JNI bridge
vice_jni.c"] - VICE["VICE C64 core
(own pthread)"] - end - end - - WatchC <-->|Bluetooth AppMessage| CFP - CFP <-->|HTTP loopback 127.0.0.1:8888| HTTP - HTTP --> UI - UI --> JNI - JNI <--> VICE -``` +![System overview diagram](diagrams/system-overview.png) ## 2. Pebble watch app @@ -40,13 +18,7 @@ graph LR single-file C watchapp built around three `Window`s on a shared stack, plus `src/pkjs/index.js` running inside Core for Pebble. -```mermaid -stateDiagram-v2 - [*] --> Splash - Splash --> Main: 1800ms timer\n(window_stack_remove splash) - Main --> Wheel: SELECT\n(push wheel) - Wheel --> Main: SELECT (send + pop)\nor BACK (cancel, pop) -``` +![Watch app window stack state diagram](diagrams/watch-window-stack.png) - **Splash** — `BitmapLayer` showing `resources/splash.png`, centered, black backdrop. Pushed *on top of* the already-pushed Main window at startup (not @@ -80,24 +52,7 @@ overrides, + 25 newlines). ## 3. Android companion app -```mermaid -graph TD - Main["Main/UI thread
Choreographer vsync loop, touch events"] - Vice["VICE thread
main_program() → maincpu_mainloop()"] - Http["NanoHTTPD worker thread(s)
one per request"] - StdoutT["stdout reader thread"] - StderrT["stderr reader thread"] - Audio["OpenSL ES callback thread"] - - Main -->|"captureFrame(): reads g_framebuf"| Vice - Main -->|"injectKey(): writes keyboard matrix"| Vice - Http -->|"onKey → injectWatchKey → injectKey"| Vice - Http -->|"getScreenText(): reads C64 RAM"| Vice - Vice -->|"writes g_framebuf, drains pending queues"| Main - Vice -->|stdout/stderr pipes| StdoutT - Vice -->|stdout/stderr pipes| StderrT - Vice -->|sound ring buffer| Audio -``` +![Companion app thread diagram](diagrams/companion-threads.png) - **Main/UI thread** — `MainActivity`'s `Choreographer.postFrameCallback` loop drives rendering: on every hardware vsync it calls `display.captureFrame(engine)` @@ -134,24 +89,7 @@ graph TD ### 4.1 Screen mirror (VICE → watch) -```mermaid -sequenceDiagram - participant VICE as VICE thread - participant JNI as vice_jni.c - participant HTTP as NanoHTTPD /screen - participant JS as PebbleKit JS - participant Watch as Watch app (KEY_SCREEN) - - loop every 1s - JS->>HTTP: GET /screen - HTTP->>JNI: getScreenText() - JNI->>VICE: mem_read_screen($0400..$07E7) - JNI-->>HTTP: UTF-8 text (40x25, \n per row) - HTTP-->>JS: {"text": "..."} - JS->>Watch: AppMessage KEY_SCREEN - Watch->>Watch: update ScrollLayer/TextLayer - end -``` +![Screen mirror sequence diagram](diagrams/screen-mirror-flow.png) `getScreenText()` reads C64 screen RAM at the fixed default address `$0400` (same assumption `autostart.c` makes when checking for KERNAL "READY." text — @@ -171,62 +109,18 @@ to the standard conversion: ### 4.2 On-screen keyboard input (phone touch → VICE) -```mermaid -sequenceDiagram - participant User - participant KB as C64KeyboardView - participant Main as MainActivity - participant JNI as vice_jni.c - participant VICE as VICE keyboard matrix - - User->>KB: touch down/up on key - KB->>Main: onKeyEvent(key, pressed) - Main->>JNI: engine.injectKey(code, pressed) (per code, for composites) - JNI->>VICE: keyboard_set_keyarr(row, col, pressed) -``` +![On-screen keyboard input sequence diagram](diagrams/keyboard-input-flow.png) Composite keys (e.g. ↑ = LSHIFT + CUR_UD) carry a list of codes; all are pressed/released together. ### 4.3 Watch key wheel input (watch → VICE) -```mermaid -sequenceDiagram - participant Watch - participant JS as PebbleKit JS - participant HTTP as NanoHTTPD /key - participant Main as MainActivity - participant JNI as vice_jni.c - - Watch->>Watch: SELECT opens wheel; UP/DOWN rotate; SELECT confirms - Watch->>JS: AppMessage KEY_COMMAND = "