|
| 1 | +// SPDX-License-Identifier: CC-BY-SA-4.0 |
| 2 | +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell |
| 3 | += NeuroPhone Android tree — Gossamer/Java-shim migration |
| 4 | +:toc: |
| 5 | +:toclevels: 3 |
| 6 | + |
| 7 | +Status of the Android Kotlin→Rust/Gossamer migration (epic #83, |
| 8 | +sub-issues #109/#111/#112/#113/#114/#115) as of this PR. Read alongside |
| 9 | +`docs/migrations/RFC-ANDROID-KOTLIN-TO-RUST.adoc` (the original RFC, resolved |
| 10 | +2026-06-02) and `docs/migrations/JNI-SURFACE-AUDIT.adoc` (the JNI signature |
| 11 | +table) — this file records where this PR *diverges* from those, and why, |
| 12 | +rather than repeating them. |
| 13 | + |
| 14 | +== Honest status (done / staged / open) |
| 15 | + |
| 16 | +[cols="2,3,2", options="header"] |
| 17 | +|=== |
| 18 | +| Sub-issue | What's here | Verified how |
| 19 | + |
| 20 | +| #109 (WebView + JNI host) |
| 21 | +| `NeurophoneActivity` + `NeurophoneBridge` — real WebView host, real JS |
| 22 | + interface, real (already-merged) JNI ABI underneath. |
| 23 | +| Rust ABI side is merged + host-tested (`crates/neurophone-android`, |
| 24 | + `cargo test -p neurophone-android` — 5/5 pass, pre-existing, re-run this |
| 25 | + session). Java side: |
| 26 | + reviewed for correctness against documented Android WebView/JNI-interface |
| 27 | + semantics; **not compiled** (no Android SDK stub jar in this environment — |
| 28 | + `javac` alone cannot resolve `android.*`). Staged/unverified on-device. |
| 29 | + |
| 30 | +| #111 (Service) |
| 31 | +| `NeurophoneService` — real `android.app.Service`, direct JNI calls. |
| 32 | +| Same staged/unverified status as #109. gossamer itself has **no** Service |
| 33 | + primitive (verified: `hyperpolymath/gossamer` Android surface is limited to |
| 34 | + the WebView Activity + JS bridge) — this extends `android.app.Service` |
| 35 | + directly, not a gossamer base class. See "Design notes" below. |
| 36 | + |
| 37 | +| #112 (BootReceiver) |
| 38 | +| `BootReceiver` — real `android.content.BroadcastReceiver`, single JNI path. |
| 39 | +| Same staged/unverified status. gossamer has zero foundation for this |
| 40 | + (verified: no `BroadcastReceiver` anywhere in gossamer) — written from |
| 41 | + scratch. |
| 42 | + |
| 43 | +| #113 (Widget triple) |
| 44 | +| `NeurophoneAppWidget` + `NeurophoneWidgetActions` + layout/xml/string |
| 45 | + resources. Configure activity **dropped**, repeating the decision already |
| 46 | + made once in this repo's own history. |
| 47 | +| Same staged/unverified status. `res/layout/widget_neurophone.xml`, |
| 48 | + `res/xml/neurophone_widget_info.xml`, `res/values/strings.xml`, and the two |
| 49 | + vector drawables are recovered near-verbatim from the pre-deletion legacy |
| 50 | + tree (`git show f389983^:android/app/src/main/res/...`) since they are |
| 51 | + pure resources with no Kotlin/bridge logic to migrate. |
| 52 | + |
| 53 | +| #114 (AffineScript UI) |
| 54 | +| `assets/gossamer-ui/{index.html,styles.css,src/logic.affine, |
| 55 | + dist/logic.deno.js,dist/logic.harness.mjs,dist/ui.mjs,build.sh,deno.json}`. |
| 56 | + Adapted from neurophone's own deleted prior attempt (`a3487cc`); its |
| 57 | + assumptions were wrong (config shape, bridge transport, and — a discovery |
| 58 | + this attempt could make that `a3487cc` couldn't — the very idea of an |
| 59 | + `extern` "host object with methods" isn't expressible by the real |
| 60 | + compiler) — see `assets/gossamer-ui/README.adoc` "What changed vs |
| 61 | + a3487cc". |
| 62 | +| **Genuinely mixed, not uniformly staged.** `src/logic.affine` (pure |
| 63 | + decision logic) is really type-checked, really compiled, and really |
| 64 | + tested: `affinescript check` passes, `affinescript compile --deno-esm` |
| 65 | + produced the committed `dist/logic.deno.js`, and `deno run --allow-read |
| 66 | + dist/logic.harness.mjs` passes 19 assertions — all genuinely run this |
| 67 | + session (transcript in `assets/gossamer-ui/README.adoc` "AffineScript |
| 68 | + verification"), against a real `affinescript` 0.1.1 compiler found on |
| 69 | + `PATH` in the authoring environment (contradicting the earlier assumption, |
| 70 | + inherited from `a3487cc`, that no such compiler was available anywhere). |
| 71 | + `dist/ui.mjs` (hand-written DOM + `NeurophoneBridge` wiring — deliberately |
| 72 | + *not* AffineScript, see its file header) passes `deno check`/`deno lint` |
| 73 | + cleanly but has not run in a real WebView (no Android SDK/NDK/emulator |
| 74 | + here). `index.html`/`styles.css` are static assets, visually unverified. |
| 75 | + |
| 76 | +| #115 (delete legacy `android/` Kotlin tree) |
| 77 | +| Nothing to do — already true. Re-verified this session, see below. |
| 78 | +| `git ls-files -- 'android/**/*.kt' 'android/**/*.kts'` (pre-this-PR) returns |
| 79 | + empty; `Language Policy Enforcement` and `governance / Language / package |
| 80 | + anti-pattern policy` are both green on `main` already. Not force-closing |
| 81 | + the issue — left for the owner per the task brief. |
| 82 | +|=== |
| 83 | + |
| 84 | +No changes were made to any of the seven core Rust crates |
| 85 | +(`lsm`/`esn`/`bridge`/`sensors`/`llm`/`claude-client`/`neurophone-core`) or to |
| 86 | +`crates/neurophone-android` — the 11-method JNI ABI these shims call was |
| 87 | +already complete and merged (issue #110) before this PR; verified by reading |
| 88 | +`crates/neurophone-android/src/{lib,state,error,sensor_map}.rs` directly |
| 89 | +rather than assuming the RFC/audit doc's historical description. |
| 90 | + |
| 91 | +== Design notes (why this PR looks different from the RFC) |
| 92 | + |
| 93 | +=== 1. No gossamer Zig/Idris2 core is vendored |
| 94 | + |
| 95 | +Sub-issue #109 describes hosting the UI "via the JNI bridge (per gossamer |
| 96 | +`src/interface/abi/Types.idr`)". This PR does **not** link gossamer's Zig |
| 97 | +FFI layer (`webview_android.zig`) or its Idris2 ABI into neurophone. Doing so |
| 98 | +would mean vendoring gossamer's build (Zig + Idris2 toolchains, its own |
| 99 | +`libgossamer.so`) as a neurophone build dependency, which is unbuildable in |
| 100 | +this environment (no such toolchain wired into neurophone's `Cargo.toml` |
| 101 | +workspace, no NDK to link an Android `.so` at all) and is out of scope for |
| 102 | +this migration on its own. |
| 103 | + |
| 104 | +Instead, `NeurophoneActivity`/`NeurophoneBridge` reuse gossamer's *proven |
| 105 | +Java-side pattern* directly: `hyperpolymath/gossamer |
| 106 | +android/src/main/java/io/gossamer/GossamerActivity.java` (full-screen |
| 107 | +`WebView`, hardened `WebSettings`, one JS-interface object, careful |
| 108 | +`onDestroy`) and `GossamerBridge.java` (one `@JavascriptInterface` class |
| 109 | +added via `addJavascriptInterface`) — verified by reading both files |
| 110 | +directly. `android.webkit.WebView` is used from Java without any native |
| 111 | +Zig layer in between, which is, in fact, exactly what gossamer's own |
| 112 | +`webview_android.zig` itself ultimately calls into via JNI — so this cuts a |
| 113 | +native-code layer we cannot build here, rather than skipping something |
| 114 | +gossamer would otherwise provide. |
| 115 | + |
| 116 | +=== 2. Synchronous bridge, not gossamer's async IPC |
| 117 | + |
| 118 | +gossamer's real injected bridge (`hyperpolymath/gossamer |
| 119 | +src/interface/ffi/src/main.zig`, lines ~2013-2091) is |
| 120 | +`window.gossamer`/`window.__gossamer_invoke(name, payload) => Promise`, |
| 121 | +routed through a Zig-side command-dispatch/bindings map, resolved later via |
| 122 | +`evaluateJavascript`. Since this migration doesn't vendor that dispatcher, |
| 123 | +`NeurophoneBridge` instead exposes each `NativeLib` call as a directly |
| 124 | +callable, synchronous `@JavascriptInterface` method — a legitimate, simpler |
| 125 | +Android idiom (`addJavascriptInterface` calls block the JS thread and return |
| 126 | +values synchronously) that fits neurophone's actual situation: every |
| 127 | +`NativeLib` call is already a fast in-process JNI call with no |
| 128 | +cross-process queue to wait on. `dist/ui.mjs` reflects this directly (no |
| 129 | +`Promise`/`await` plumbing around the bridge calls). |
| 130 | + |
| 131 | +A further, more fundamental correction only discoverable with a working |
| 132 | +compiler in hand: `a3487cc`'s `bridge.affine` modelled the bridge as a |
| 133 | +*typed extern object* (`extern let host: { invoke: (...) -> ... }`). The |
| 134 | +real `affinescript` compiler's deno-esm backend cannot express that at all |
| 135 | +— its `extern fn` lowering (`lib/codegen_deno.ml`) only supports a |
| 136 | +compiler-maintained intrinsic table or a bare same-named global function |
| 137 | +call, never an arbitrary `obj.method(args)`. Combined with this compiler's |
| 138 | +stdlib documenting that it has no typed DOM binding yet either |
| 139 | +(`stdlib/Canvas.affine`: blocked on `affinescript-dom`, issue #255), the |
| 140 | +bridge/DOM layer is not expressible in AffineScript today at all — so this |
| 141 | +PR draws the line differently from `a3487cc`: `src/logic.affine` holds only |
| 142 | +the pure decision logic (genuinely compiled + tested, see |
| 143 | +`assets/gossamer-ui/README.adoc` "AffineScript verification"), and |
| 144 | +`dist/ui.mjs` is a hand-written harness around it, mirroring the upstream |
| 145 | +compiler's own `tests/codegen-deno/*.harness.mjs` test convention rather |
| 146 | +than inventing a new pattern. |
| 147 | + |
| 148 | +This is also, concretely, why neurophone's *own* prior AffineScript UI |
| 149 | +attempt (`a3487cc`) had to be corrected rather than reused verbatim: it |
| 150 | +assumed the gossamer async contract above and an extern shape the compiler |
| 151 | +doesn't support, neither of which could have been checked at the time (no |
| 152 | +compiler on hand). See `assets/gossamer-ui/README.adoc` "What changed vs |
| 153 | +a3487cc" for the `SystemConfig` shape correction too. |
| 154 | + |
| 155 | +=== 3. RFC Q6 ("contribute upstream to gossamer") was never executed — verified, and superseded here |
| 156 | + |
| 157 | +`docs/migrations/RFC-ANDROID-KOTLIN-TO-RUST.adoc` Q6 resolved (2026-06-02) |
| 158 | +to build a `gossamer-android-services` companion module upstream in |
| 159 | +`hyperpolymath/gossamer` with four shim base classes (`Service` / |
| 160 | +`BroadcastReceiver` / `AppWidgetProvider` / `Activity`), with neurophone's |
| 161 | +shims becoming thin subclasses of those. |
| 162 | + |
| 163 | +Re-checked directly against `hyperpolymath/gossamer` this session: that |
| 164 | +companion module does not exist. gossamer's Android surface is exactly |
| 165 | +`GossamerActivity` + `GossamerBridge`, nothing else. Q6 was resolved but |
| 166 | +never implemented. |
| 167 | + |
| 168 | +Rather than block this migration on filing and landing a new upstream |
| 169 | +gossamer PR (out of scope for a neurophone-only change), this PR reverts to |
| 170 | +the RFC's originally-proposed **Path (A)**: hand-written Java shims under |
| 171 | +`android/` extending Android framework classes (`Service`, |
| 172 | +`BroadcastReceiver`, `AppWidgetProvider`, `Activity`) directly. If a genuine |
| 173 | +gossamer companion module lands upstream later, these shims are small enough |
| 174 | +to re-point as a follow-up. |
| 175 | + |
| 176 | +=== 4. Directory layout: `android/app/src/main/java/...`, not the RFC's flat `android/src/main/java/...` |
| 177 | + |
| 178 | +The RFC's component table (and Q6) describe shims living at |
| 179 | +`android/src/main/java/ai/neurophone/` — matching gossamer's own flat layout |
| 180 | +(`android/src/main/java/io/gossamer/`). This PR uses |
| 181 | +`android/app/src/main/java/ai/neurophone/` instead (the `app/`-module shape |
| 182 | +the *legacy* Kotlin tree used), for two independently-verified reasons: |
| 183 | + |
| 184 | +. **The live estate CI carve-out requires it.** The Java/Kotlin ban's |
| 185 | + exemption in `hyperpolymath/standards` |
| 186 | + `.github/workflows/governance-reusable.yml` (RFC Q1, `standards#341`, |
| 187 | + landed 2026-06-02) is the shell regex |
| 188 | + `git ls-files '*.java' '*.kt' '*.kts' '*.groovy' | grep -vE '(^|/)android/.*/src/.*\.java$'`. |
| 189 | + Tested directly: `android/app/src/main/java/ai/neurophone/NativeLib.java` |
| 190 | + matches this pattern (exempt); the RFC's own proposed flat |
| 191 | + `android/src/main/java/ai/neurophone/NativeLib.java` does **not** — there |
| 192 | + is no `/src/` substring left over once `android/` is consumed, because |
| 193 | + there's no intervening path segment. This is a real, verifiable |
| 194 | + discrepancy between the RFC's own proposed layout and the CI rule the RFC |
| 195 | + itself caused to be written; worth a maintainer's eyes even though it |
| 196 | + doesn't block this PR (this PR uses the layout that actually satisfies the |
| 197 | + rule). |
| 198 | +. **Existing, already-committed tooling assumes it.** `scripts/build-android.sh` |
| 199 | + (untouched by this PR) already hard-codes |
| 200 | + `ANDROID_APP="$PROJECT_ROOT/android/app"` and copies built `.so` files to |
| 201 | + `$ANDROID_APP/src/main/jniLibs/<abi>/`; `scripts/install-on-phone.sh` |
| 202 | + (also untouched) invokes `(cd android && ./gradlew :app:assembleRelease)`. |
| 203 | + Both predate this PR and both assume the `android/app/` shape, not the |
| 204 | + RFC's flat one. |
| 205 | + |
| 206 | +=== 5. No Gradle build was added |
| 207 | + |
| 208 | +The RFC (Q4) resolved to keep Gradle, just switch DSL flavour |
| 209 | +(Kotlin `*.gradle.kts` → Groovy `*.gradle`), reasoning that "Gossamer's |
| 210 | +existing `android/` directory uses raw Gradle". Re-checked directly: it does |
| 211 | +not — `hyperpolymath/gossamer android/` contains exactly two Java files and a |
| 212 | +README, no Gradle files at all. That premise was wrong. |
| 213 | +`scripts/install-on-phone.sh` still assumes a Gradle wrapper |
| 214 | +(`./gradlew :app:assembleRelease`) that has never existed in this repo's |
| 215 | +`android/` tree in the form this PR restores it (the legacy Kotlin tree's own |
| 216 | +`*.gradle.kts` files are gone from history's most recent state too). Adding |
| 217 | +speculative Gradle build files with no way to invoke or verify them here (no |
| 218 | +Gradle/NDK/Android-SDK in this environment) would be unverifiable ceremony, |
| 219 | +so none were added. Building an installable APK remains an explicit follow-up |
| 220 | +gated on real Android tooling, not something this PR claims to have solved. |
| 221 | + |
| 222 | +== Hygiene: stale `.hypatia-baseline.json` / `.hypatia-ignore` entries |
| 223 | + |
| 224 | +Both files carried entries for the legacy Kotlin/Gradle paths (tracking |
| 225 | +issue #97), which no longer exist. This PR updates both to reference the new |
| 226 | +Java shim paths added here and to point at this PR's issues instead, rather |
| 227 | +than leaving dead references sitting next to newly-added live ones. See the |
| 228 | +PR description for the exact diff. |
| 229 | + |
| 230 | +== What would still be needed for a real, installable build |
| 231 | + |
| 232 | +* An Android SDK + NDK (`cargo-ndk` or equivalent) to cross-compile |
| 233 | + `crates/neurophone-android` and produce `libneurophone_android.so` for |
| 234 | + `arm64-v8a`/`armeabi-v7a`/`x86_64` — `scripts/build-android.sh` already |
| 235 | + handles this gracefully when absent (falls back to a host-only build), but |
| 236 | + actually producing the `.so` needs the toolchain. |
| 237 | +* Either a Gradle module (`android/build.gradle`, `android/settings.gradle`, |
| 238 | + `android/app/build.gradle`, matching RFC Q4's Groovy-DSL decision) or some |
| 239 | + other non-Gradle native Android packaging path, wired to |
| 240 | + `scripts/install-on-phone.sh`'s existing `./gradlew :app:assembleRelease` |
| 241 | + expectation (or a replacement of that expectation). |
| 242 | +* The `affinescript` compiler vendored somewhere reachable by CI (it was |
| 243 | + found on `PATH` in this authoring environment and genuinely used — see |
| 244 | + `assets/gossamer-ui/README.adoc` "AffineScript verification" — but nothing |
| 245 | + in CI currently re-runs `bash build.sh` / `deno task test:ui` to catch |
| 246 | + drift between `src/logic.affine` and the committed `dist/logic.deno.js`). |
| 247 | +* `affinescript-dom` (or equivalent) landing upstream, so the DOM/bridge |
| 248 | + wiring currently hand-written in `dist/ui.mjs` could move into typed |
| 249 | + AffineScript too — out of neurophone's control, tracked upstream in |
| 250 | + `hyperpolymath/affinescript` issue #255. |
| 251 | +* Device/emulator verification of the full lifecycle (service start/stop, |
| 252 | + boot-restart, widget render/toggle, webview query round-trip) — none of |
| 253 | + which could be exercised in this authoring environment. |
| 254 | + |
| 255 | +None of the above blocks this PR from being reviewed as a real, honest step: |
| 256 | +the JNI ABI it calls into is genuinely merged and tested; `src/logic.affine` |
| 257 | +is genuinely type-checked, compiled, and behaviourally tested; the |
| 258 | +Java/hand-written-JS it adds is genuinely new source reviewed for |
| 259 | +platform-API correctness, just not yet run in a real WebView or on a |
| 260 | +device. |
0 commit comments