diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..e26aa51 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: MPL-2.0 +# Android component-host logic tests — runs the pure-Zig registry/dispatch/ +# JSON/directive tests for the JNI binding and the Service/Receiver/Widget +# hosts (src/interface/ffi/src/{jni,android_*}.zig). +# +# These are deliberately NOT folded into the E2E `zig build test` gate: that +# gate runs under `2>/dev/null`, which hides Zig compile errors. This workflow +# runs `zig build test-android` with visible output so failures are debuggable. +# Non-blocking by design (no device/NDK needed — the modules are pure Zig). + +name: Android Component Tests + +on: + pull_request: + branches: ['**'] + paths: + - 'src/interface/ffi/**' + - '.github/workflows/android.yml' + push: + branches: [main, master] + paths: + - 'src/interface/ffi/**' + - '.github/workflows/android.yml' + +permissions: + contents: read + +concurrency: + group: android-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-tests: + name: zig build test-android + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Install Zig + uses: goto-bus-stop/setup-zig@abea47f85e598557f500fa1fd2ab7464fcb39406 # v2 + with: + version: 0.15.2 + + - name: Run Android component host tests + run: cd src/interface/ffi && zig build test-android diff --git a/.gitignore b/.gitignore index e4e5456..fc527a9 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ deps/ .cache/ build/ dist/ + +# Android per-ABI native build outputs (produced by `just android-build`) +android/gossamer-android-services/src/main/jniLibs/ diff --git a/.hypatia-baseline.json b/.hypatia-baseline.json new file mode 100644 index 0000000..4c10d6f --- /dev/null +++ b/.hypatia-baseline.json @@ -0,0 +1,34 @@ +[ + { + "severity": "high", + "severity_override": "advisory", + "rule_module": "code_safety", + "type": "zig_ptr_cast", + "file": "src/interface/ffi/src/jni.zig", + "note": "Inherent to the JNI binding: dispatching through the per-thread JNINativeInterface vtable requires reinterpreting opaque table slots as typed function pointers. This is CWE-704 by construction, not a defect — it is the identical pattern std.DynLib.lookup uses for dlsym results, and is documented at each call site with a // SAFETY: note. The only alternative is hand-transcribing the full ~232-entry NDK vtable as a typed struct, which is more error-prone than the cast. Owner-acknowledged; kept advisory (visible, non-blocking)." + }, + { + "severity": "medium", + "severity_override": "advisory", + "rule_module": "code_safety", + "type": "zig_ptr_cast", + "file": "src/interface/ffi/src/jni.zig", + "note": "See the high-severity entry: inherent JNI vtable function-pointer cast; advisory." + }, + { + "severity": "high", + "severity_override": "advisory", + "rule_module": "code_safety", + "type": "zig_align_cast", + "file": "src/interface/ffi/src/jni.zig", + "note": "Inherent to the JNI binding: @alignCast pairs with the vtable/env @ptrCast (same std.DynLib.lookup pattern). Not independently fixable; advisory (visible, non-blocking)." + }, + { + "severity": "medium", + "severity_override": "advisory", + "rule_module": "code_safety", + "type": "zig_align_cast", + "file": "src/interface/ffi/src/jni.zig", + "note": "See the high-severity entry: inherent JNI vtable alignment cast; advisory." + } +] diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 26b7f54..8e904aa 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -6,7 +6,7 @@ [metadata] project = "gossamer" version = "0.3.1" -last-updated = "2026-05-20" +last-updated = "2026-06-02" status = "active" # active | paused | archived [project-context] @@ -41,7 +41,7 @@ milestones = [ { name = "Phase 2c: Streaming IPC (gossamer_emit, g_idle_add, JS/Rust/ReScript bindings)", completion = 100 }, { name = "Phase 3: Integration tests + hot reload", completion = 100 }, { name = "Phase 4: macOS and Windows support", completion = 100 }, - { name = "Phase 5: Mobile (iOS and Android)", completion = 70 }, + { name = "Phase 5: Mobile (iOS and Android)", completion = 82 }, { name = "Phase 6: Plugin system (dlopen vtable, load/unload/list, liveness check)", completion = 100 }, ] @@ -52,7 +52,9 @@ milestones = [ actions = [ "arXiv submission: attempt at arxiv.org (cs.PL / cs.SE) using OU .ac.uk email — may bypass endorsement; Zenodo draft 19535676 already published as preprint", "IDApTIK desktop migration: replace Tauri with Gossamer for desktop builds (staging decision required)", - "Android NDK build integration and Gradle scaffolding for full Android support", + "Android: NDK CI runner to build libgossamer.so per ABI + emulator smoke test of the four handler paths", + "Android: APK/AAB assembly + signing pipeline (aapt2/d8/r8/apksigner, gradle-free)", + "Android: settle Ephapax region-calculus modelling of long-lived Service handles (cross-region Service→Widget refs)", "App Store packaging, code signing, and notarisation for iOS", ] @@ -158,3 +160,72 @@ depends-on = ["ephapax", "idris2", "zig", "webkitgtk"] # standards#131 CLOSED owner-authorised at the discharge point # with #41 merge-pending honestly noted in the closing comment. # Epic standards#124 progress: 5/12 sub-issues closed (was 4/12). +# 2026-06-02: Android grown from "WebView in an Activity" to a first-class mobile +# shell hosting the non-UI components, on the critical path for +# neurophone#83 (migrate banned Kotlin Android app onto gossamer). +# [1] JNI made REAL: jni.zig models the JNINativeInterface vtable in +# pure Zig (ordinal-indexed, ...A-variant calls, JavaVM thread +# attach, global refs). The old `extern fn jni_*` symbols were +# defined NOWHERE — the bridge could never link. webview_android.zig +# rewritten onto it; fixed latent bugs (stored local refs that die +# after nativeInit; double JS-interface registration). +# [2] Platform dispatch FIXED: main.zig selected GTK for Android +# (os.tag == .linux), so webview_android.zig was dead code. Now +# routed by `abi == .android`. build.zig links NDK log/android and +# skips GTK for the android abi. +# [3] NEW native component hosts: android_service.zig (foreground +# Service), android_receiver.zig (BroadcastReceiver), +# android_widget.zig (AppWidgetProvider) + android_components.zig +# (process-global handler registry, JSON event / kv-directive wire +# formats). App-facing gossamer_{service,receiver,widget}_bind +# exports reuse the webview IPC callback ABI — apps write pure +# Rust/Zig/Idris handlers, never touch JNI. +# [4] Idris2 ABI: Gossamer.ABI.AndroidComponents — terminal-teardown +# + dispatch-only-while-live proofs per component; the long-lived +# Service modelled as a LinearHandle over the SAME Allocated/Active/ +# Consumed machine as the webview (linearity crosses the boundary). +# Wired into gossamer-abi.ipkg (now 14 modules) + namespace symlink. +# [5] JVM shims GENERATED, not hand-authored: tools/android-codegen +# emits generated/android/ (Activity/Bridge/Service/Receiver/Widget +# + manifest skeleton). Hand-authored android/src Java DELETED — the +# only Java in the repo is now generated. Generated Java compiles +# clean against Android API stubs; every `native` decl matches a Zig +# `export fn Java_*` (symbol parity checked). +# [6] Host-runnable Zig tests for the component registry/dispatch/parse +# logic wired into `zig build test` (test-android step). +# Remaining (raised as issues): NDK CI build + emulator smoke test; +# APK/AAB + signing pipeline; Ephapax region modelling of long-lived +# Service handles. Phase 5 70 -> 82. +# 2026-06-02: Pivot to the owner-signed-off #71 design (gossamer-android-services +# companion). PR #70 reworked from the directive/generated-shim model +# to a SUBCLASS base-class model (owner pick "Replace, same branch"): +# [1] REMOVED the directive layer (android_components/service/receiver/ +# widget.zig + gossamer-generated/ + tools/android-codegen). +# [2] KEPT jni.zig (extended with float-array access for sensors), +# build.zig android support, and the Idris lifecycle proofs. +# [3] NEW: four abstract Java base classes apps extend (~5-20 LoC) under +# android/gossamer-android-services/src/main/java/io/gossamer/ — +# GossamerForegroundService/BootReceiver/AppWidgetProvider (services +# pkg) + GossamerActivity onIntentReceived hook. `final` lifecycle. +# [4] NEW: services_android.zig — independent ServiceHandle + the +# Java_io_gossamer_services_* JNI exports; app native core registers +# callbacks via gossamer_android_register_* (apps never touch JNI). +# [5] AndroidComponents.idr foreign decls swapped bind->register ABI. +# Java javac-verified clean vs Android stubs; symbol parity Java<->Zig +# checked. Unblocks neurophone#97 sub-PRs #3-#9. +# 2026-06-02: PR #70 follow-through (toolchains installed locally: zig 0.15.2 via +# PyPI, idris2 0.8.0 from source, affinescript 0.1.1 via apt OCaml): +# [1] CLI link fix — wired ipc_handlers.zig into the FFI root main.zig +# so gossamer_channel_register_defaults is exported; the native CLI +# links again (pre-existing main failure; e2e §3). Symbols 123->124. +# [2] e2e §5 gate — honour the %unsafe pragma so the sanctioned class-J +# axiom (PanelIsolation.stringNotEqCommut, standards#131) passes while +# unannotated believe_me/assert_total still fail (pre-existing main). +# [3] jni-cast findings — all 5 vtable function-pointer casts now carry +# // SAFETY: notes; .hypatia-baseline.json advisory (owner-ack); both +# Advanced-Security review threads dispositioned + resolved. +# Local verification: tests/e2e.sh PASS=13/FAIL=0/SKIP=1; zig build + +# test + test-android pass; idris2 --typecheck gossamer-abi.ipkg 14/14. +# ReScript->AffineScript IPC-binding migration split to its own PR #73 +# (bindings/affinescript/src/Gossamer.affine, affinescript-check clean; +# standards#252) to keep #70 focused on the Android #71 companion. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c5c3bb..89e8b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Android — `gossamer-android-services` companion: four shim base classes + real JNI (Phase 5, #71)**: Grows the Android target from "a WebView in an Activity" into a first-class mobile shell hosting the non-UI components, on the critical path for neurophone#97 (migrating a banned-Kotlin Android app onto gossamer). Owner-signed-off subclass model (issue #71). + - `src/interface/ffi/src/jni.zig` (new) — a REAL JNI binding in pure Zig: models the `JNINativeInterface`/`JNIInvokeInterface` tables (ordinal-indexed), uses the `...A`-variant calls (no C varargs across the boundary), caches the `JavaVM` for thread attach, uses global refs, and reads `float[]` sensor samples via `Get/ReleaseFloatArrayElements`. The previous bridge declared `extern fn jni_*` symbols defined nowhere, so it could never link. + - **Four abstract Java base classes** under `android/gossamer-android-services/src/main/java/io/gossamer/` that downstream apps EXTEND with ~5-20 lines (lifecycle methods are `final`): `services/GossamerForegroundService` (Service + SensorEventListener), `services/GossamerBootReceiver` (BroadcastReceiver), `services/GossamerAppWidgetProvider` (AppWidgetProvider), and the existing `GossamerActivity` extended with an `onIntentReceived` hook. gossamer owns every JNI call. + - `src/interface/ffi/src/services_android.zig` (new) — the native host: independent opaque `ServiceHandle` (separate from the webview handle) and the `Java_io_gossamer_services_*` JNI exports. Apps plug their Rust/Zig core in via a process-global callback registry (`gossamer_android_register_service_callbacks` / `_widget_callbacks` / `_boot_callback` / `_intent_callback`) — they never touch JNI. Config and widget state cross the boundary as JSON; sensors as primitive `int[]`. + - `src/interface/abi/AndroidComponents.idr` (wired into `gossamer-abi.ipkg`) — terminal-teardown and dispatch-only-while-live proofs per component; the long-lived Service is a `LinearHandle` over the same Allocated/Active/Consumed machine as the webview, so linearity crosses the JNI boundary. Zero `believe_me`. + - `webview_android.zig` rewritten onto `jni.zig` (fixes latent ref/registration bugs); `main.zig` selects the Android backend by `abi == .android` (it was falling through to GTK); `build.zig` links NDK `log`/`android` and skips WebKitGTK for the android abi. + - Host-runnable native tests (`zig build test-android`, plus a dedicated `Android Component Tests` workflow with visible output); `just android-build` per-ABI cross-compile. + - Design + "ready-to-depend-on" checklist: `docs/architecture/android-components.adoc`; module guide: `android/gossamer-android-services/README.adoc`. + - **Conf — real JSON loader for `gossamer.conf.json` (Phase 14a.4, #27)**: Replaces the hand-rolled string-scan parser in `cli/src/main.zig` (`parseConfig` + `extractStringField/IntField/BoolField`) with a `std.json`-backed loader exposed through libgossamer - `src/interface/ffi/src/conf.zig` — six exports, all capability-gated on FileSystem (kind=0): `gossamer_conf_load(path, cap_token) -> opaque*`, `gossamer_conf_get_string(conf*, dotted_path) -> *char | null`, `gossamer_conf_get_int(conf*, path, default) -> i64`, `gossamer_conf_get_bool(conf*, path, default) -> i32`, `gossamer_conf_has(conf*, path) -> i32`, `gossamer_conf_free(conf*) -> void`. - Dotted-path lookup so callers target nested keys explicitly: `productName` (top-level), `build.devUrl` (nested), `app.windows.0.width` (array element). Fixes the old scanner's "first match wins" bug where a key like `title` matched at the wrong nesting level. diff --git a/Justfile b/Justfile index 417468a..99dfd45 100644 --- a/Justfile +++ b/Justfile @@ -159,6 +159,31 @@ build-freebsd: build-all-platforms: build-ffi build-macos-x64 build-macos-arm build-windows build-linux-arm build-linux-riscv @echo "Built for: linux-x64, macos-x64, macos-arm64, windows-x64, linux-arm64, linux-riscv64" +# ─── Android (gossamer-android-services companion, issue #71) ─── +# Run the host-runnable Android native logic tests (jni.zig + services_android.zig; +# no NDK needed). The on-device JNI paths are validated on an emulator (issue #67). +android-test: + cd src/interface/ffi && zig build test-android + +# Cross-compile libgossamer.so for every Android ABI (requires ANDROID_NDK_HOME). +# Produces a jniLibs tree the consuming app bundles. The JNI WebView backend and +# the service/receiver/widget native host are selected automatically for +# *-android targets. +android-build: + #!/usr/bin/env bash + set -euo pipefail + : "${ANDROID_NDK_HOME:?set ANDROID_NDK_HOME to your NDK (r26+)}" + declare -A ABI=( [aarch64-linux-android]=arm64-v8a \ + [x86_64-linux-android]=x86_64 \ + [arm-linux-androideabi]=armeabi-v7a ) + for tgt in "${!ABI[@]}"; do + echo "==> $tgt (${ABI[$tgt]})" + ( cd src/interface/ffi && zig build -Dtarget="$tgt" -Doptimize=ReleaseSafe ) + mkdir -p "android/gossamer-android-services/src/main/jniLibs/${ABI[$tgt]}" + cp "src/interface/ffi/zig-out/lib/libgossamer.so" "android/gossamer-android-services/src/main/jniLibs/${ABI[$tgt]}/" 2>/dev/null || true + done + @echo "Android .so built for: arm64-v8a, x86_64, armeabi-v7a (see android/gossamer-android-services/src/main/jniLibs/)" + # Show supported platform targets platforms: @echo "=== Gossamer Supported Platforms ===" @@ -174,9 +199,14 @@ platforms: @echo " openbsd-x64 WebKitGTK zig build -Dtarget=x86_64-openbsd" @echo " netbsd-x64 WebKitGTK zig build -Dtarget=x86_64-netbsd" @echo "" - @echo "Mobile (Phase 3 — v0.4.0+):" - @echo " ios-arm64 WKWebView/UIKit planned" - @echo " android-arm64 Android WebView planned" + @echo "Mobile (Phase 5):" + @echo " ios-arm64 WKWebView/UIKit zig build -Dtarget=aarch64-ios" + @echo " android-arm64 Android WebView zig build -Dtarget=aarch64-linux-android (just android-build)" + @echo " android-x86_64 Android WebView zig build -Dtarget=x86_64-linux-android (emulator)" + @echo " android-armv7 Android WebView zig build -Dtarget=arm-linux-androideabi" + @echo "" + @echo " Android also hosts native Service / BroadcastReceiver / AppWidgetProvider" + @echo " components — see docs/architecture/android-components.adoc." # ═══════════════════════════════════════════════════════════════ # Run diff --git a/ROADMAP.adoc b/ROADMAP.adoc index 013ac93..ca83adc 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -112,18 +112,40 @@ Mobile platforms using platform-native webviews. * [ ] Push notification integration * [ ] Safe area insets handling -=== Android (webview_android.zig) — Partially Implemented - -* [x] JNI type definitions and entry points -* [x] GossamerActivity native init/destroy via JNI_OnLoad -* [x] WebviewState with JNI references -* [x] jni_NewObject for GossamerBridge constructor (fixed from CallObjectMethod, 2026-04-03) -* [x] nativeInit accepts and caches screen_width/screen_height params -* [ ] JNI FindClass / GetMethodID / CallVoidMethod for WebView operations -* [ ] loadData / loadUrl / evaluateJavascript via JNI -* [ ] addJavascriptInterface for IPC bridge -* [ ] Android NDK build integration -* [ ] Gradle project scaffolding for Java launcher +=== Android — WebView + native component hosts (2026-06-02) + +WebView shell: + +* [x] Real JNI binding (`jni.zig`): JNINativeInterface vtable, `...A`-variant + calls, cached JavaVM for thread attach, global refs. (Replaces the previous + `extern fn jni_*` symbols that were defined nowhere — the bridge now links.) +* [x] Platform dispatch routes Android by `abi == .android` (was falling through + to GTK); `build.zig` links NDK `log`/`android`, skips WebKitGTK. +* [x] loadDataWithBaseURL / loadUrl / evaluateJavascript / setTitle via JNI +* [x] GossamerActivity nativeInit(Activity, WebView, int, int) / nativeDestroy +* [x] GossamerBridge `@JavascriptInterface` IPC (single registration) + +Native non-UI components — `gossamer-android-services` companion (issue #71, +owner-signed-off subclass model): + +* [x] Four abstract Java base classes apps extend (`final` lifecycle) under + `android/gossamer-android-services/src/main/java/io/gossamer/`: + `services/GossamerForegroundService` (Service + SensorEventListener), + `services/GossamerBootReceiver`, `services/GossamerAppWidgetProvider`, and + `GossamerActivity` extended with an `onIntentReceived` hook +* [x] `services_android.zig`: independent `ServiceHandle` + the + `Java_io_gossamer_services_*` JNI exports; app native core plugs in via the + `gossamer_android_register_*` callback registry (apps never touch JNI) +* [x] Config / widget-state as JSON; sensors as primitive `int[]` +* [x] Idris2 lifecycle proofs (`Gossamer.ABI.AndroidComponents`): terminal + teardown, dispatch-only-while-live, Service as a LinearHandle +* [x] Host-runnable native tests (`zig build test-android` + dedicated workflow) + +Remaining (tracked as issues): + +* [ ] NDK CI runner: build `libgossamer.so` per ABI + emulator smoke test +* [ ] APK/AAB assembly + signing (aapt2/d8/r8/apksigner, gradle-free) +* [ ] Ephapax region-calculus modelling of long-lived Service handles == Phase 4 — Production (v1.0.0) diff --git a/android/README.adoc b/android/README.adoc index 414c748..f68bfcb 100644 --- a/android/README.adoc +++ b/android/README.adoc @@ -1,4 +1,39 @@ // SPDX-License-Identifier: MPL-2.0 = Android -Android platform target for gossamer. -Contains the native Android source tree used to build the linearly-typed webview shell on Android, consuming the Idris2 ABI and Zig FFI layers. +:toc: macro + +Android platform target for gossamer: a native webview shell *and* native hosts for the non-UI Android components (foreground `Service`, `BroadcastReceiver`, `AppWidgetProvider`), consuming the Idris2 ABI and Zig FFI layers. + +The supported model is the OWNER-SIGNED-OFF *subclass base-class model* of issue #71: gossamer ships four abstract Java base classes that a downstream app *extends* with ~5-20 lines each, and owns all JNI itself. See `gossamer-android-services/` and `docs/architecture/android-components.adoc`. + +== Where the code lives + +[cols="1,3"] +|=== +| Java base classes (#71) | `gossamer-android-services/src/main/java/io/gossamer/` +| — foreground service | `services/GossamerForegroundService.java` +| — boot receiver | `services/GossamerBootReceiver.java` +| — app-widget provider | `services/GossamerAppWidgetProvider.java` +| — webview activity / bridge | `GossamerActivity.java`, `GossamerBridge.java` +| Native (Zig) | `src/interface/ffi/src/` +| — JNI binding | `jni.zig` (real `JNINativeInterface` vtable, pure Zig) +| — service/receiver/widget host | `services_android.zig` (reuses `jni.zig`) +| — WebView backend | `webview_android.zig` +| Formal ABI (Idris2) | `src/interface/abi/AndroidComponents.idr` +| Design + readiness | `docs/architecture/android-components.adoc` +|=== + +NOTE: There is *no hand-authored Kotlin* anywhere in this repository, and the Java base classes are *not* generated — they are authored once, as a reusable library, in `gossamer-android-services/`. That directory is the only place hand-authored Java is permitted (the estate `android/**/src/**/*.java` carve-out). A downstream app extends the base classes and keeps its own logic in a pure Rust/Zig/Idris core, registering process-global callbacks at `JNI_OnLoad`; it never touches `JNIEnv`. + +The `gossamer-generated/` tree is the earlier directive/generated-shim approach that #71 supersedes; new apps should depend on `gossamer-android-services/` instead. + +== Building + +[source,sh] +---- +just android-build # cross-compile libgossamer.so for each ABI (needs NDK) +---- + +This produces a per-ABI `jniLibs/` tree (`arm64-v8a`, `x86_64`, `armeabi-v7a`) the consuming app bundles next to the base classes. `minSdk` 26, `targetSdk` 34. + +See `gossamer-android-services/README.adoc` for the subclass pattern, the JSON config/widget-state conventions, the AndroidManifest declarations and permissions a consuming app needs, and the Gradle library-module layout. See `docs/architecture/android-components.adoc` for the JNI calling convention, the app-callback registration ABI, the linearity model, and the "ready-to-depend-on" checklist for downstream apps (e.g. neurophone#97). diff --git a/android/gossamer-android-services/README.adoc b/android/gossamer-android-services/README.adoc new file mode 100644 index 0000000..f7254e8 --- /dev/null +++ b/android/gossamer-android-services/README.adoc @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: MPL-2.0 += gossamer-android-services +:toc: macro + +Four abstract Java base classes that turn gossamer's non-UI Android components +(foreground `Service`, boot `BroadcastReceiver`, home-screen `AppWidgetProvider`) +plus the WebView `Activity` into reusable, subclass-once capabilities. This is +the OWNER-SIGNED-OFF *subclass base-class model* of gossamer issue #71. + +A downstream app (e.g. neurophone) *extends* these classes with ~5-20 lines each +and keeps its logic in its own pure Rust/Zig/Idris core. gossamer owns every line +of JNI; the app never sees `JNIEnv`. The native side lives in +`src/interface/ffi/src/services_android.zig` (which reuses the real JNI binding +`src/interface/ffi/src/jni.zig`). See +`docs/architecture/android-components.adoc` for the full design, the JNI calling +convention, the linearity model, and the "ready-to-depend-on" checklist. + +NOTE: This is the only place hand-authored Java is permitted in the estate: it +lives under the `android/**/src/**/*.java` carve-out at +`src/main/java/io/gossamer/`. There is no hand-authored Kotlin anywhere, and +these base classes are *not* generated — they are authored once here as a +library. + +toc::[] + +== The four base classes + +[cols="2,3"] +|=== +| `io.gossamer.services.GossamerForegroundService` | `extends Service implements SensorEventListener`. Long-running foreground service; owns an independent native `ServiceHandle` (a `long`). +| `io.gossamer.services.GossamerBootReceiver` | `extends BroadcastReceiver`. Restarts the service on `BOOT_COMPLETED` / `LOCKED_BOOT_COMPLETED`. +| `io.gossamer.services.GossamerAppWidgetProvider` | `extends AppWidgetProvider`. Home-screen widget; native supplies state JSON, the subclass assembles the `RemoteViews`. +| `io.gossamer.GossamerActivity` | WebView host (existing, extended with an `onIntentReceived(Intent)` hook). +|=== + +Lifecycle methods (`onCreate`, `onStartCommand`, `onDestroy`, `onReceive`, +`onUpdate`, `onNewIntent`) are `final` on the base classes: the JVM owns the +lifecycle and delegates each step to native through the handle/registry. The one +deliberately non-`final` method is +`GossamerForegroundService.onSensorChanged`, so a subclass may pre-filter or +down-sample before calling `super`. + +== The subclass pattern + +The override surface is tiny and fixed. + +.GossamerForegroundService — required + optional overrides +[cols="2,3"] +|=== +| `Notification createForegroundNotification()` | *required* — the ongoing notification +| `String getChannelId()` | *required* — notification channel id +| `int getNotificationId()` | *required* — stable `startForeground` id +| `String getNativeConfig()` | optional, default `"{}"` — JSON config for the native service +| `int[] getSubscribedSensors()` | optional, default empty — `Sensor.TYPE_*` values +| `int getSensorSamplingRate()` | optional, default `SENSOR_DELAY_GAME` +| `String getWakeLockTag()` | optional, default `null` (no wake lock) +| `void onSensorChanged(SensorEvent)` | optional (non-`final`) — pre-filter, then `super` +|=== + +[source,java] +---- +public final class NeurophoneRuntimeService extends GossamerForegroundService { + private static final String CHANNEL_ID = "neurophone_runtime"; + private static final int NOTIFICATION_ID = 0x4E50; + + @Override protected Notification createForegroundNotification() { + return new Notification.Builder(this, CHANNEL_ID) + .setContentTitle("NeuroPhone").setContentText("Listening") + .setSmallIcon(R.drawable.ic_stat_run).setOngoing(true).build(); + } + @Override protected String getChannelId() { return CHANNEL_ID; } + @Override protected int getNotificationId(){ return NOTIFICATION_ID; } + @Override protected int[] getSubscribedSensors() { + return new int[] { Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE }; + } +} +---- + +.GossamerBootReceiver +[source,java] +---- +public final class NeurophoneBootReceiver extends GossamerBootReceiver { + @Override protected Class getServiceClass() { + return NeurophoneRuntimeService.class; + } +} +---- + +.GossamerAppWidgetProvider +[source,java] +---- +public final class NeurophoneAppWidget extends GossamerAppWidgetProvider { + @Override protected int getWidgetLayout() { return R.layout.neurophone_widget; } + @Override protected String getActionPrefix(){ return "ai.neurophone.widget."; } + @Override protected void renderWidget(RemoteViews v, String stateJson, int id) { + v.setTextViewText(R.id.widget_state, field(stateJson, "state")); + } +} +---- + +The app's behaviour plugs in *below* JNI: the app's core registers +process-global callbacks at `JNI_OnLoad` +(`gossamer_android_register_service_callbacks`, +`gossamer_android_register_widget_callbacks`, +`gossamer_android_register_boot_callback`, +`gossamer_android_register_intent_callback`). The app never writes a `native` +method or an `export fn`. + +== JSON conventions + +Config and widget state cross the JNI boundary as JSON strings. The schemas are +*app-defined and documented by the app*; gossamer treats them as opaque strings. + +* *Service config* — `getNativeConfig()` returns a JSON object passed to + `nativeServiceCreate`. Default `"{}"` (empty object = no config). Example: + `{"sampleRateHz":50,"model":"salience-v3"}`. +* *Widget state* — `nativeFetchWidgetState(Context)` returns a JSON object the + subclass parses in `renderWidget` (or `null` = no state). Example: + `{"state":"Running","salience":42}`. +* *Sensors* are the deliberate exception: samples are forwarded as primitives + (`nativeSensorEvent(handle, type, float[] values, timestampNs, accuracy)`) to + avoid per-sample JSON allocation on a hot path; the subscription set is a + primitive `int[]`. + +== AndroidManifest declarations a consuming app needs + +A consuming app declares its *own subclasses* (not the gossamer base classes) in +its manifest. Minimal shape: + +[source,xml] +---- + + + + + + + + + + + + + + + + + + +---- + +The WebView `Activity` is declared as usual (`.MainActivity extends +io.gossamer.GossamerActivity`, with the `MAIN`/`LAUNCHER` intent filter). + +== Permissions + +[cols="2,3"] +|=== +| `FOREGROUND_SERVICE` | run a foreground service (all SDK levels ≥ 26) +| `FOREGROUND_SERVICE_DATA_SYNC` | the `dataSync` foreground-service type (SDK 34+) +| `POST_NOTIFICATIONS` | show the ongoing notification (SDK 33+ runtime grant) +| `RECEIVE_BOOT_COMPLETED` | receive boot broadcasts for the boot receiver +| `WAKE_LOCK` | only if the service overrides `getWakeLockTag()` +| sensor permissions | as required by the subscribed sensors (e.g. `HIGH_SAMPLING_RATE_SENSORS` for >200 Hz; `BODY_SENSORS`/`ACTIVITY_RECOGNITION` for the relevant sensor families) +|=== + +[source,xml] +---- + + + + + + +---- + +== Gradle module layout + +This directory is an Android *library module*. The app depends on it and ships +gossamer's native library alongside it as per-ABI `jniLibs`. + +[source] +---- +android/gossamer-android-services/ + src/main/ + java/io/gossamer/ + GossamerActivity.java + GossamerBridge.java + services/ + GossamerForegroundService.java + GossamerBootReceiver.java + GossamerAppWidgetProvider.java + jniLibs/ + arm64-v8a/libgossamer.so + x86_64/libgossamer.so + armeabi-v7a/libgossamer.so + src/androidTest/ # compile-only synthetic subclasses (not shipped) +---- + +`minSdk` 26, `targetSdk` 34. The three shipped ABIs are `arm64-v8a` (devices), +`x86_64` (emulator) and `armeabi-v7a`, matching the gossamer `just android-build` +output. Populate `jniLibs//libgossamer.so` from that build (see the root +`Justfile` `android-build` target). The classes call `System.loadLibrary("gossamer")` +in a static initialiser, so the `.so` must be present for each shipped ABI. + +CI compiles each base class plus the synthetic subclasses under `src/androidTest/` +against minimal Android API stubs; a "missing symbol" or "cannot override final +method" error there means the boundary contract drifted (see +`src/androidTest/README.adoc`). diff --git a/android/gossamer-android-services/src/androidTest/README.adoc b/android/gossamer-android-services/src/androidTest/README.adoc new file mode 100644 index 0000000..17b7f94 --- /dev/null +++ b/android/gossamer-android-services/src/androidTest/README.adoc @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 += gossamer-android-services — synthetic subclass tests +:toc: macro + +These three classes are *compile-only* fixtures. They are not shipped and not +linked into any app; they exist to pin down the public override surface of the +`gossamer-android-services` base classes (issue #71) so that an accidental +change to a method signature, an abstract/`final` modifier, or a default-hook +return type fails the build. + +== What they prove + +`SampleService extends GossamerForegroundService`:: +The three abstract hooks (`createForegroundNotification`, `getChannelId`, +`getNotificationId`) are implementable from app code, and an overridable +default (`getSubscribedSensors`) can be narrowed to a real sensor +(`Sensor.TYPE_ACCELEROMETER`). Confirms the `final` lifecycle is inherited +without being re-declared, and that `onSensorChanged` is left open for +subclass pre-filtering. + +`SampleBootReceiver extends GossamerBootReceiver`:: +The single abstract hook `getServiceClass()` is satisfiable with a `Class` +literal, and the `final onReceive` is inherited intact. + +`SampleWidget extends GossamerAppWidgetProvider`:: +The abstract trio (`getWidgetLayout`, `getActionPrefix`, `renderWidget`) is +implementable, including writing into the supplied `RemoteViews` from parsed +native-state JSON. Confirms `onUpdate`/`onReceive` are `final` and need no +re-implementation. + +== How they are checked + +Compiled in CI against minimal Android API stubs together with the base +classes: every base class plus every sample subclass must `javac` clean. A +"missing symbol" or "cannot override final method" error here means the +boundary contract drifted. diff --git a/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleBootReceiver.java b/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleBootReceiver.java new file mode 100644 index 0000000..49715ed --- /dev/null +++ b/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleBootReceiver.java @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Synthetic subclass proving GossamerBootReceiver's override surface compiles. +// Not shipped. +// +package io.gossamer.sample; + +import io.gossamer.services.GossamerBootReceiver; + +/** Minimal boot receiver that restarts {@link SampleService}. */ +public final class SampleBootReceiver extends GossamerBootReceiver { + + @Override + protected Class getServiceClass() { + return SampleService.class; + } +} diff --git a/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleService.java b/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleService.java new file mode 100644 index 0000000..1712f53 --- /dev/null +++ b/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleService.java @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Synthetic subclass proving GossamerForegroundService's override surface +// compiles with a minimal (~5-20 line) app implementation. Not shipped. +// +package io.gossamer.sample; + +import android.app.Notification; +import android.hardware.Sensor; + +import io.gossamer.services.GossamerForegroundService; + +/** Minimal foreground service that subscribes to the accelerometer. */ +public final class SampleService extends GossamerForegroundService { + + private static final String CHANNEL_ID = "sample_channel"; + private static final int NOTIFICATION_ID = 0x5301; + + @Override + protected Notification createForegroundNotification() { + return new Notification.Builder(this, CHANNEL_ID) + .setContentTitle("Sample") + .setContentText("Running") + .setSmallIcon(android.R.drawable.stat_notify_sync) + .setOngoing(true) + .build(); + } + + @Override + protected String getChannelId() { + return CHANNEL_ID; + } + + @Override + protected int getNotificationId() { + return NOTIFICATION_ID; + } + + @Override + protected int[] getSubscribedSensors() { + return new int[] { Sensor.TYPE_ACCELEROMETER }; + } +} diff --git a/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleWidget.java b/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleWidget.java new file mode 100644 index 0000000..8abe32c --- /dev/null +++ b/android/gossamer-android-services/src/androidTest/java/io/gossamer/sample/SampleWidget.java @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Synthetic subclass proving GossamerAppWidgetProvider's override surface +// compiles. Not shipped. +// +package io.gossamer.sample; + +import android.widget.RemoteViews; + +import io.gossamer.services.GossamerAppWidgetProvider; + +/** Minimal widget provider that renders a text field from the native state. */ +public final class SampleWidget extends GossamerAppWidgetProvider { + + @Override + protected int getWidgetLayout() { + return 0; // placeholder; a real app returns R.layout.sample_widget + } + + @Override + protected String getActionPrefix() { + return "io.gossamer.sample."; + } + + @Override + protected void renderWidget(RemoteViews views, String nativeStateJson, int widgetId) { + // Minimal parse: pull a "label" string field out of the flat JSON state. + String label = extractLabel(nativeStateJson); + views.setTextViewText(android.R.id.text1, label); + } + + private static String extractLabel(String json) { + if (json == null) { + return ""; + } + final String key = "\"label\":\""; + int start = json.indexOf(key); + if (start < 0) { + return ""; + } + start += key.length(); + int end = json.indexOf('"', start); + if (end < 0) { + return ""; + } + return json.substring(start, end); + } +} diff --git a/android/gossamer-android-services/src/main/java/io/gossamer/GossamerActivity.java b/android/gossamer-android-services/src/main/java/io/gossamer/GossamerActivity.java new file mode 100644 index 0000000..758decf --- /dev/null +++ b/android/gossamer-android-services/src/main/java/io/gossamer/GossamerActivity.java @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// gossamer-android-services: subclass-based shim base classes (issue #71). +// Apps EXTEND this class with a handful of lines; the lifecycle is final and +// native delegation goes through JNI static native methods. Hand-authored on +// purpose (not generated): this is the carve-out "services" surface. +// +package io.gossamer; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.util.DisplayMetrics; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +/** + * GossamerActivity — full-screen WebView host that bridges to libgossamer.so. + * + *

Creates a full-screen {@link WebView} (JavaScript + DOM storage on, file + * and content access off), registers a single {@link GossamerBridge} as the + * {@code GossamerBridge} JavaScript interface, hands the Activity, WebView and + * screen size to the native layer via {@link #nativeInit}, then loads + * {@link #getInitialHtml()} or {@link #getInitialUrl()}. + * + *

Subclass and override {@link #getInitialUrl()}/{@link #getInitialHtml()} to + * supply app content, and optionally {@link #onIntentReceived(Intent)} to react + * to new intents. Lifecycle methods are deliberately {@code final}. + * + *

The JNI signatures match the Zig host exactly: + *

    + *
  • {@code Java_io_gossamer_GossamerActivity_nativeInit} + *
  • {@code Java_io_gossamer_GossamerActivity_nativeDestroy} + *
  • {@code Java_io_gossamer_GossamerActivity_nativeIntentReceived} + *
+ */ +public class GossamerActivity extends Activity { + + private WebView webView; + + static { + System.loadLibrary("gossamer"); + } + + private static native void nativeInit(Activity activity, WebView webview, int screenWidth, int screenHeight); + private static native void nativeDestroy(); + private static native void nativeIntentReceived(Intent intent); + + @Override + protected final void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + webView = new WebView(this); + setContentView(webView); + + WebSettings settings = webView.getSettings(); + settings.setJavaScriptEnabled(true); + settings.setDomStorageEnabled(true); + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + + webView.setWebViewClient(new WebViewClient()); + webView.addJavascriptInterface(new GossamerBridge(), "GossamerBridge"); + + DisplayMetrics dm = getResources().getDisplayMetrics(); + nativeInit(this, webView, dm.widthPixels, dm.heightPixels); + + String html = getInitialHtml(); + if (html != null) { + webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null); + } else { + String url = getInitialUrl(); + if (url != null) { + webView.loadUrl(url); + } + } + } + + @Override + protected final void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + onIntentReceived(intent); + } + + @Override + protected final void onDestroy() { + nativeDestroy(); + if (webView != null) { + webView.removeJavascriptInterface("GossamerBridge"); + webView.destroy(); + webView = null; + } + super.onDestroy(); + } + + /** + * Hook invoked when the Activity is re-delivered an Intent. The default + * forwards it to the native layer; override to pre-process (call super to + * keep native delivery). + */ + protected void onIntentReceived(Intent intent) { + nativeIntentReceived(intent); + } + + /** Override to load an initial URL. Return null to use getInitialHtml(). */ + protected String getInitialUrl() { + return null; + } + + /** Override to provide initial HTML. Return null if using getInitialUrl(). */ + protected String getInitialHtml() { + return "

Gossamer

Override getInitialHtml() or getInitialUrl().

"; + } +} diff --git a/android/gossamer-android-services/src/main/java/io/gossamer/GossamerBridge.java b/android/gossamer-android-services/src/main/java/io/gossamer/GossamerBridge.java new file mode 100644 index 0000000..78908e6 --- /dev/null +++ b/android/gossamer-android-services/src/main/java/io/gossamer/GossamerBridge.java @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// gossamer-android-services: subclass-based shim base classes (issue #71). +// +package io.gossamer; + +import android.webkit.JavascriptInterface; + +/** + * GossamerBridge — JavaScript interface exposed to the WebView. + * + *

{@code window.GossamerBridge.postMessage(json)} forwards the IPC message to + * the native layer via {@link #nativePostMessage}, which dispatches to the bound + * handler and replies through {@code window.__gossamer_callbacks} via + * {@code evaluateJavascript()}. + * + *

{@code nativePostMessage} is an INSTANCE native method to match + * {@code Java_io_gossamer_GossamerBridge_nativePostMessage} on the Zig side. + */ +public final class GossamerBridge { + + public native void nativePostMessage(String message); + + /** + * Message shape: {@code {"id":"abc","name":"command","payload":"{...}"}}. + * Null/empty messages are dropped before crossing the JNI boundary. + */ + @JavascriptInterface + public void postMessage(String message) { + if (message == null || message.isEmpty()) { + return; + } + nativePostMessage(message); + } +} diff --git a/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerAppWidgetProvider.java b/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerAppWidgetProvider.java new file mode 100644 index 0000000..51fceec --- /dev/null +++ b/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerAppWidgetProvider.java @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// gossamer-android-services: subclass-based shim base classes (issue #71). +// +package io.gossamer.services; + +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.widget.RemoteViews; + +import androidx.annotation.LayoutRes; + +/** + * GossamerAppWidgetProvider — abstract base for a home-screen widget backed by + * libgossamer.so. + * + *

{@link RemoteViews} can only be built JVM-side, so widget state is fetched + * from native as a JSON string ({@link #nativeFetchWidgetState}) and the + * subclass renders it into a {@link RemoteViews} ({@link #renderWidget}). Custom + * taps (intents whose action starts with {@link #getActionPrefix()}) are routed + * to native ({@link #nativeHandleWidgetAction}) and then every instance is + * re-rendered. Subclasses supply the layout, action prefix and render logic. + * + *

JNI symbols (must match the Zig host exactly): + *

    + *
  • {@code Java_io_gossamer_services_GossamerAppWidgetProvider_nativeFetchWidgetState} + *
  • {@code Java_io_gossamer_services_GossamerAppWidgetProvider_nativeHandleWidgetAction} + *
+ */ +public abstract class GossamerAppWidgetProvider extends AppWidgetProvider { + + static { + System.loadLibrary("gossamer"); + } + + private static native String nativeFetchWidgetState(Context context); + private static native void nativeHandleWidgetAction(Context context, String action, int widgetId); + + /** Layout resource ({@code R.layout.*}) inflated for each widget instance. */ + @LayoutRes + protected abstract int getWidgetLayout(); + + /** Intent-action prefix that marks a tap as belonging to this widget. */ + protected abstract String getActionPrefix(); + + /** + * Render the native state JSON into the given RemoteViews for one instance. + * + * @param views the RemoteViews to populate + * @param nativeStateJson state fetched from the native layer + * @param widgetId the AppWidget id being rendered + */ + protected abstract void renderWidget(RemoteViews views, String nativeStateJson, int widgetId); + + @Override + public final void onUpdate(Context context, AppWidgetManager manager, int[] ids) { + String state = nativeFetchWidgetState(context); + for (int id : ids) { + RemoteViews views = new RemoteViews(context.getPackageName(), getWidgetLayout()); + renderWidget(views, state, id); + manager.updateAppWidget(id, views); + } + } + + @Override + public final void onReceive(Context context, Intent intent) { + super.onReceive(context, intent); + String action = (intent != null) ? intent.getAction() : null; + if (action != null && action.startsWith(getActionPrefix())) { + int widgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1); + nativeHandleWidgetAction(context, action, widgetId); + + AppWidgetManager manager = AppWidgetManager.getInstance(context); + int[] ids = manager.getAppWidgetIds(new ComponentName(context, getClass())); + onUpdate(context, manager, ids); + } + } +} diff --git a/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerBootReceiver.java b/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerBootReceiver.java new file mode 100644 index 0000000..47560e0 --- /dev/null +++ b/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerBootReceiver.java @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// gossamer-android-services: subclass-based shim base classes (issue #71). +// +package io.gossamer.services; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; + +/** + * GossamerBootReceiver — abstract base for restarting a service on boot. + * + *

On {@code BOOT_COMPLETED} / {@code LOCKED_BOOT_COMPLETED} it asks the native + * layer whether the service should be restarted ({@link #nativeShouldRestart}, + * keyed by the service class name) and, if so, starts it as a foreground + * service. Subclasses provide only {@link #getServiceClass()}. + * + *

JNI symbol (must match the Zig host exactly): + * {@code Java_io_gossamer_services_GossamerBootReceiver_nativeShouldRestart}. + */ +public abstract class GossamerBootReceiver extends BroadcastReceiver { + + static { + System.loadLibrary("gossamer"); + } + + private static native boolean nativeShouldRestart(Context context, String serviceClassName); + + /** The foreground service class to (re)start on boot. */ + protected abstract Class getServiceClass(); + + @Override + public final void onReceive(Context context, Intent intent) { + String action = (intent != null) ? intent.getAction() : null; + if (action == null) { + return; + } + if (!Intent.ACTION_BOOT_COMPLETED.equals(action) + && !Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)) { + return; + } + Class serviceClass = getServiceClass(); + if (nativeShouldRestart(context, serviceClass.getName())) { + context.startForegroundService(new Intent(context, serviceClass)); + } + } +} diff --git a/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerForegroundService.java b/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerForegroundService.java new file mode 100644 index 0000000..8ccaceb --- /dev/null +++ b/android/gossamer-android-services/src/main/java/io/gossamer/services/GossamerForegroundService.java @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// gossamer-android-services: subclass-based shim base classes (issue #71). +// +package io.gossamer.services; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.os.Build; +import android.os.IBinder; +import android.os.PowerManager; + +/** + * GossamerForegroundService — abstract base for a long-running foreground + * {@link Service} driven by libgossamer.so. + * + *

Lifecycle methods are {@code final}: the JVM owns the lifecycle and each + * step is delegated to the native side through an independent service handle + * (a {@code long} returned by {@link #nativeServiceCreate}). Subclasses supply + * only the JVM-object work that cannot be done natively — building the + * {@link Notification}, naming the channel — plus optional config and sensor + * subscriptions. Roughly 5-20 lines of subclass code. + * + *

Config and state cross the boundary as JSON strings + * ({@link #getNativeConfig()}). Sensors are subscribed via a primitive + * {@code int[]} of {@code Sensor.TYPE_*} values ({@link #getSubscribedSensors()}) + * and each event is forwarded raw to {@link #nativeSensorEvent}. + * + *

JNI symbols (must match the Zig host exactly): + *

    + *
  • {@code Java_io_gossamer_services_GossamerForegroundService_nativeServiceCreate} + *
  • {@code Java_io_gossamer_services_GossamerForegroundService_nativeServiceStartCommand} + *
  • {@code Java_io_gossamer_services_GossamerForegroundService_nativeServiceDestroy} + *
  • {@code Java_io_gossamer_services_GossamerForegroundService_nativeSensorEvent} + *
+ */ +public abstract class GossamerForegroundService extends Service implements SensorEventListener { + + static { + System.loadLibrary("gossamer"); + } + + private static native long nativeServiceCreate(Service self, String configJson); + private static native int nativeServiceStartCommand(long handle, Intent intent, int flags, int startId); + private static native void nativeServiceDestroy(long handle); + private static native void nativeSensorEvent(long handle, int sensorType, float[] values, long timestampNs, int accuracy); + + private long nativeHandle; + private SensorManager sensorManager; + private PowerManager.WakeLock wakeLock; + + // ---- abstract hooks the subclass MUST implement ----------------------- + + /** Build the ongoing notification shown while the service runs in foreground. */ + protected abstract Notification createForegroundNotification(); + + /** Notification channel id; the channel is created at IMPORTANCE_LOW if absent. */ + protected abstract String getChannelId(); + + /** Stable notification id used by startForeground(). */ + protected abstract int getNotificationId(); + + // ---- overridable hooks with sensible defaults ------------------------- + + /** JSON config handed to the native service at creation. Defaults to "{}". */ + protected String getNativeConfig() { + return "{}"; + } + + /** Sensor types ({@code Sensor.TYPE_*}) to subscribe to. Empty = none. */ + protected int[] getSubscribedSensors() { + return new int[0]; + } + + /** Sampling rate for subscribed sensors. Defaults to SENSOR_DELAY_GAME. */ + protected int getSensorSamplingRate() { + return SensorManager.SENSOR_DELAY_GAME; + } + + /** Wake-lock tag; return null (default) to run without a wake lock. */ + protected String getWakeLockTag() { + return null; + } + + // ---- final lifecycle (delegates to native) ---------------------------- + + @Override + public final void onCreate() { + super.onCreate(); + + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm != null && nm.getNotificationChannel(getChannelId()) == null) { + nm.createNotificationChannel( + new NotificationChannel(getChannelId(), getChannelId(), NotificationManager.IMPORTANCE_LOW)); + } + + nativeHandle = nativeServiceCreate(this, getNativeConfig()); + + String tag = getWakeLockTag(); + if (tag != null) { + PowerManager pm = getSystemService(PowerManager.class); + if (pm != null) { + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag); + wakeLock.setReferenceCounted(false); + wakeLock.acquire(); + } + } + + sensorManager = getSystemService(SensorManager.class); + if (sensorManager != null) { + int rate = getSensorSamplingRate(); + for (int sensorType : getSubscribedSensors()) { + Sensor sensor = sensorManager.getDefaultSensor(sensorType); + if (sensor != null) { + sensorManager.registerListener(this, sensor, rate); + } + } + } + } + + @Override + public final int onStartCommand(Intent intent, int flags, int startId) { + Notification notification = createForegroundNotification(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(getNotificationId(), notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC); + } else { + startForeground(getNotificationId(), notification); + } + return nativeServiceStartCommand(nativeHandle, intent, flags, startId); + } + + @Override + public final void onDestroy() { + if (sensorManager != null) { + sensorManager.unregisterListener(this); + sensorManager = null; + } + if (wakeLock != null) { + if (wakeLock.isHeld()) { + wakeLock.release(); + } + wakeLock = null; + } + nativeServiceDestroy(nativeHandle); + super.onDestroy(); + } + + @Override + public final IBinder onBind(Intent intent) { + return null; + } + + // ---- SensorEventListener ---------------------------------------------- + + /** + * Forwards each sensor sample to the native service. NOT final on purpose: + * subclasses may pre-filter or down-sample, calling super to deliver. + */ + @Override + public void onSensorChanged(SensorEvent event) { + nativeSensorEvent(nativeHandle, event.sensor.getType(), event.values, event.timestamp, event.accuracy); + } + + @Override + public final void onAccuracyChanged(Sensor sensor, int accuracy) { + // no-op + } +} diff --git a/android/src/main/java/io/gossamer/GossamerActivity.java b/android/src/main/java/io/gossamer/GossamerActivity.java deleted file mode 100644 index 882b0b3..0000000 --- a/android/src/main/java/io/gossamer/GossamerActivity.java +++ /dev/null @@ -1,96 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) - -package io.gossamer; - -import android.app.Activity; -import android.os.Bundle; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; - -/** - * GossamerActivity — Hosts a WebView and bridges to the native Gossamer library. - * - * This Activity creates a full-screen WebView and passes global JNI references - * to the native layer via nativeInit(). The native library (libgossamer.so) is - * loaded via System.loadLibrary in a static block. - * - * Subclass this and override getInitialUrl() or getInitialHtml() to provide - * your application content. - */ -public class GossamerActivity extends Activity { - - private WebView webView; - - static { - System.loadLibrary("gossamer"); - } - - // Native methods implemented in webview_android.zig - private static native void nativeInit(Activity activity, WebView webview); - private static native void nativeDestroy(); - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - webView = new WebView(this); - setContentView(webView); - - // Configure WebView for Gossamer IPC - WebSettings settings = webView.getSettings(); - settings.setJavaScriptEnabled(true); - settings.setDomStorageEnabled(true); - settings.setAllowFileAccess(false); // Security: no file:// access - settings.setAllowContentAccess(false); - - // Prevent WebView from opening external browser - webView.setWebViewClient(new WebViewClient()); - - // Register the GossamerBridge JavaScript interface - GossamerBridge bridge = new GossamerBridge(); - webView.addJavascriptInterface(bridge, "GossamerBridge"); - - // Pass global references to the native layer - nativeInit(this, webView); - - // Load initial content - String html = getInitialHtml(); - if (html != null) { - webView.loadData(html, "text/html", "UTF-8"); - } else { - String url = getInitialUrl(); - if (url != null) { - webView.loadUrl(url); - } - } - } - - @Override - protected void onDestroy() { - nativeDestroy(); - if (webView != null) { - webView.removeJavascriptInterface("GossamerBridge"); - webView.destroy(); - webView = null; - } - super.onDestroy(); - } - - /** - * Override to provide an initial URL to load. - * Return null to use getInitialHtml() instead. - */ - protected String getInitialUrl() { - return null; - } - - /** - * Override to provide initial HTML content. - * Return null if using getInitialUrl(). - */ - protected String getInitialHtml() { - return "

Gossamer

Override getInitialHtml() or getInitialUrl().

"; - } -} diff --git a/android/src/main/java/io/gossamer/GossamerBridge.java b/android/src/main/java/io/gossamer/GossamerBridge.java deleted file mode 100644 index 304cd3b..0000000 --- a/android/src/main/java/io/gossamer/GossamerBridge.java +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) - -package io.gossamer; - -import android.webkit.JavascriptInterface; - -/** - * GossamerBridge — JavaScript interface exposed to the WebView. - * - * When JavaScript calls `window.GossamerBridge.postMessage(msg)`, - * the message is forwarded to the native Gossamer library via JNI. - * The native side parses the JSON message, dispatches to the bound - * callback, and sends the response back via evaluateJavascript(). - * - * This class is registered via WebView.addJavascriptInterface() in - * GossamerActivity.onCreate(). - */ -public class GossamerBridge { - - // Native method implemented in webview_android.zig - private static native void nativePostMessage(String message); - - /** - * Called from JavaScript via the Gossamer IPC bridge. - * - * The message is a JSON string with the following structure: - * {"id":"abc123","name":"command_name","payload":"{...}"} - * - * @param message JSON-encoded IPC message - */ - @JavascriptInterface - public void postMessage(String message) { - if (message == null || message.isEmpty()) { - return; - } - nativePostMessage(message); - } -} diff --git a/cli/src/main.zig b/cli/src/main.zig index b65a8b8..cda3129 100644 --- a/cli/src/main.zig +++ b/cli/src/main.zig @@ -66,6 +66,10 @@ extern fn gossamer_set_csp(handle: u64, csp: [*:0]const u8) c_int; extern fn gossamer_registry_add(handle: u64) u32; extern fn gossamer_groove_discover() u32; extern fn gossamer_groove_status(target_id: u32) u32; +// Hot-reload file watcher (implemented in file_watcher.zig). The watcher handle +// is an opaque pointer owned by libgossamer; null means "failed to start". +extern fn gossamer_watcher_start(handle: u64, config_json: [*:0]const u8, frontend_dist: [*:0]const u8) ?*anyopaque; +extern fn gossamer_watcher_stop(opaque_handle: ?*anyopaque) void; const AppMode = enum { gui, diff --git a/docs/architecture/android-components.adoc b/docs/architecture/android-components.adoc new file mode 100644 index 0000000..52c874f --- /dev/null +++ b/docs/architecture/android-components.adoc @@ -0,0 +1,438 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += Gossamer Android: native component hosting +:toc: macro +:toclevels: 3 + +Status: *draft / Phase 5*. This document specifies how gossamer hosts the +non-UI Android components (`Service`, `BroadcastReceiver`, `AppWidgetProvider`) +as first-class gossamer capabilities, alongside the existing WebView `Activity`, +and defines what a downstream app (e.g. neurophone, issue link:https://github.com/hyperpolymath/neurophone/issues/97[neurophone#97]) +must satisfy to ship a production Android app on gossamer. + +This is the OWNER-SIGNED-OFF *subclass base-class model* of gossamer issue #71. +It supersedes the earlier directive/generated-shim design: gossamer no longer +generates per-app JVM shims, and apps no longer return tab-separated directives. +Instead gossamer *ships* four abstract Java base classes that a downstream app +*extends* with ~5-20 lines, while gossamer keeps owning every line of JNI. + +toc::[] + +== Problem + +gossamer began as a desktop webview shell and grew an Android target that hosts +a single `WebView` inside an `Activity` over a JNI bridge. A real app needs more +than a window: a long-running foreground `Service`, a `BroadcastReceiver` (boot, +custom actions) and a home-screen `AppWidgetProvider`. Before this work those +were left for each app to hand-roll in Kotlin — exactly the banned-language debt +neurophone#83 is trying to clear. + +The earlier gossamer approach generated complete JVM shims and pushed *all* +JVM-object decisions (which `Notification`, which `RemoteViews`, which channel) +back into native code as a tab-separated "directive" mini-language. That worked +on paper but had two problems: the directive grammar grew without bound as apps +needed richer notifications/widgets, and a generated, never-edited shim cannot +express the small amount of genuinely app-specific JVM glue (a custom +`Notification.Builder` chain, a non-trivial `RemoteViews` layout) without +re-encoding the entire Android UI surface as wire records. + +The #71 goal: make Service / Receiver / Widget *gossamer* capabilities by +shipping a *small, fixed, abstract* JVM surface. gossamer owns the lifecycle and +all JNI; the app subclasses each base class and writes only the handful of lines +that must run JVM-side, plus its pure Rust/Zig/Idris core behind a process-global +callback registry. No per-app codegen; no directive grammar. + +== Design principles + +. *gossamer owns all JNI.* The app never sees `JNIEnv`, never writes an + `export fn Java_…`, never declares a `native` method. JNI lives in + `src/interface/ffi/src/jni.zig` (a real `JNINativeInterface` vtable binding in + pure Zig) and in the new host module `src/interface/ffi/src/services_android.zig`, + which reuses `jni.zig`. The four base classes declare the `native` methods; + their bodies are gossamer's `export fn`s. +. *Subclass, do not generate.* gossamer ships four ABSTRACT base classes. The app + EXTENDS them — it does not run a code generator and does not receive a + copy-me-verbatim shim tree. The override surface is tiny and fixed (three or + four abstract methods per class, a few optional overrides). This is the + OWNER-SIGNED-OFF reversal of the old "JVM shim is generated, never + hand-authored" rule: the JVM shim is now *authored once, in gossamer*, as a + reusable library, and the app contributes only its subclass. +. *Lifecycle methods are `final`.* `onCreate`, `onStartCommand`, `onDestroy`, + `onReceive`, `onUpdate` are declared `final` on the base classes. The JVM owns + the lifecycle; each step is delegated to native through the handle. A subclass + cannot accidentally break the native-delegation contract by re-implementing a + lifecycle method — the compiler rejects it. (The one deliberate exception is + `GossamerForegroundService.onSensorChanged`, left open so a subclass may + pre-filter or down-sample before calling `super`.) +. *JVM-object work stays in Java.* Building a `Notification` or a `RemoteViews` + can only happen JVM-side and is genuinely app-specific. In the subclass model + that work lives in the *app's subclass* (`createForegroundNotification()`, + `renderWidget(...)`), not in a generated shim and not in native directives. + The native side never constructs JVM UI objects; it only supplies state as + JSON and reacts to events. +. *Independent `ServiceHandle`.* Each gossamer-hosted Service owns its own native + handle — a `long` returned by `nativeServiceCreate` and stored on the Service + instance — entirely separate from the webview's handle. A Service can run with + no Activity and no webview alive (a boot restart is the canonical case), so its + native state must not be hung off the webview. +. *Linearity preserved across the boundary.* The formal lifecycle/linearity model + is unchanged and still lives in `Gossamer.ABI.AndroidComponents` (Idris2, + `idris2 --typecheck`-gated). The Service is a `LinearHandle` over the same + Allocated/Active/Consumed machine as the webview; teardown is terminal; dispatch + is only well-typed while live. See <>. + +== Architecture + +[source] +---- + Android OS + │ instantiates the APP's subclass by class name, drives lifecycle + ▼ + app subclass (Java, ~5-20 lines) gossamer base class (Java, shipped) + NeurophoneRuntimeService io.gossamer.services.GossamerForegroundService + NeurophoneBootReceiver extends io.gossamer.services.GossamerBootReceiver + NeurophoneAppWidget io.gossamer.services.GossamerAppWidgetProvider + (MainActivity) io.gossamer.GossamerActivity + │ final lifecycle methods call gossamer's native methods + ▼ + native host (Zig) reuses the real JNI binding + src/interface/ffi/src/services_android.zig ──uses──▶ src/interface/ffi/src/jni.zig + │ looks up the app's registered callback in a process-global registry + ▼ + app core callback (Rust/Zig/Idris) + registered at JNI_OnLoad via gossamer_android_register_*_callbacks(...) +---- + +The base class is the only JVM code gossamer ships; the app's subclass is the +only JVM code the app writes. Everything below the base class — JNI, the handle, +the registry, dispatch — is gossamer's. The app's behaviour plugs in *below* +JNI, as a C-ABI callback, never above it. + +A component can fire when there is *no Activity and no webview alive* (a boot +broadcast is the canonical case), so app callbacks are registered +*process-globally*, not hung off a webview handle. Apps register once at +`JNI_OnLoad`/core init. + +== JNI calling convention + +The base classes declare `private static native` (and one instance-`native`) +methods; gossamer defines them as `export fn`s in `services_android.zig`. The +binding rules are exactly those already used by the webview backend, via +`jni.zig`: + +* `JNIEnv` is the per-thread function table. `jni.zig` models it as a pointer to + the `JNINativeInterface` table and calls each entry *by its canonical ordinal*, + casting the slot to the real signature at the call site, rather than + hand-transcribing all ~232 struct fields. Method invocation uses the `...A` + variants (`CallVoidMethodA`, `NewObjectA`, `CallStaticVoidMethodA`) with a + `jvalue[]` array — *no C varargs across the boundary* (a real arm64 hazard when + a 64-bit `jlong`/`ServiceHandle` is passed through `...`). +* Native methods are mangled `Java_io_gossamer_services__` for the + three service classes (`GossamerForegroundService`, `GossamerBootReceiver`, + `GossamerAppWidgetProvider`) and `Java_io_gossamer_GossamerActivity_` + for the WebView host. Each `native` declaration on a base class has exactly one + matching `export fn` in the host module; the correspondence is checked + symbol-for-symbol. +* Threads the JVM calls in on (every lifecycle callback) already carry a valid + env. Native threads (the webview run-loop, async workers, a sensor-processing + thread inside the app core) attach via the cached process `JavaVM` + (`AttachCurrentThread`/`GetEnv`) rather than reusing another thread's env — + `JNIEnv` is strictly thread-local. +* Local refs the JVM hands in (the `Service` self-reference, `Context`, `Intent`, + `WebView`) are promoted to *global* refs before being stored across calls, and + deleted on teardown. Each bridged native method clears any pending exception + before returning to the JVM (a bridged method must never return with an + exception in flight). + +== The four base classes + +gossamer ships these four classes under +`android/gossamer-android-services/src/main/java/io/gossamer/`. That path places +the Java under the estate's `android/**/src/**/*.java` carve-out, the only place +hand-authored Java is permitted. Each is `abstract` (except `GossamerActivity`, +which is concrete-with-defaults so it can be used directly or subclassed); each +keeps lifecycle methods `final`; each declares its own `native` methods. + +=== GossamerForegroundService + +`io.gossamer.services.GossamerForegroundService extends Service implements SensorEventListener`. + +Native methods: `nativeServiceCreate(Service, configJson) -> long`, +`nativeServiceStartCommand(handle, Intent, flags, startId) -> int`, +`nativeServiceDestroy(handle)`, +`nativeSensorEvent(handle, sensorType, float[] values, timestampNs, accuracy)`. + +`onCreate` creates the notification channel if absent, calls +`nativeServiceCreate(this, getNativeConfig())` to obtain the independent +`ServiceHandle`, optionally acquires a partial wake lock, and registers as a +`SensorEventListener` for each subscribed sensor. `onStartCommand` builds the +foreground notification (in the subclass), calls `startForeground(...)` with +`FOREGROUND_SERVICE_TYPE_DATA_SYNC` on SDK 34+, then forwards to +`nativeServiceStartCommand`. `onDestroy` unregisters sensors, releases the wake +lock, and calls `nativeServiceDestroy(handle)` — the terminal consume of the +linear handle. + +The subclass MUST implement: + +[source,java] +---- +public final class NeurophoneRuntimeService extends GossamerForegroundService { + private static final String CHANNEL_ID = "neurophone_runtime"; + private static final int NOTIFICATION_ID = 0x4E50; + + @Override protected Notification createForegroundNotification() { + return new Notification.Builder(this, CHANNEL_ID) + .setContentTitle("NeuroPhone").setContentText("Listening") + .setSmallIcon(R.drawable.ic_stat_run).setOngoing(true).build(); + } + @Override protected String getChannelId() { return CHANNEL_ID; } + @Override protected int getNotificationId(){ return NOTIFICATION_ID; } + + // optional overrides: + @Override protected int[] getSubscribedSensors() { + return new int[] { Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE }; + } +} +---- + +Optional overrides (with defaults): `getNativeConfig()` (JSON, default `"{}"`), +`getSubscribedSensors()` (default empty `int[]`), `getSensorSamplingRate()` +(default `SENSOR_DELAY_GAME`), `getWakeLockTag()` (default `null` = no wake +lock). `onSensorChanged` is non-`final` so a subclass may pre-filter and call +`super` to deliver. + +=== GossamerBootReceiver + +`io.gossamer.services.GossamerBootReceiver extends BroadcastReceiver`. + +Native method: `nativeShouldRestart(Context, serviceClassName) -> boolean`. + +`final onReceive` ignores everything except `BOOT_COMPLETED` / +`LOCKED_BOOT_COMPLETED`, asks native (keyed by the service class name) whether to +restart, and if so calls `startForegroundService(...)`. + +The subclass MUST implement: + +[source,java] +---- +public final class NeurophoneBootReceiver extends GossamerBootReceiver { + @Override protected Class getServiceClass() { + return NeurophoneRuntimeService.class; + } +} +---- + +=== GossamerAppWidgetProvider + +`io.gossamer.services.GossamerAppWidgetProvider extends AppWidgetProvider`. + +Native methods: `nativeFetchWidgetState(Context) -> String` (state JSON), +`nativeHandleWidgetAction(Context, action, widgetId)`. + +`RemoteViews` cannot be built cleanly from JNI, so they are assembled in Java: +the native side only supplies *state JSON*. `final onUpdate` fetches the state +JSON once, then for each widget id inflates a `RemoteViews` and hands it plus the +state to the subclass's `renderWidget`. `final onReceive` routes taps whose +action starts with `getActionPrefix()` to `nativeHandleWidgetAction`, then +re-renders every instance. + +The subclass MUST implement: + +[source,java] +---- +public final class NeurophoneAppWidget extends GossamerAppWidgetProvider { + @Override protected int getWidgetLayout() { return R.layout.neurophone_widget; } + @Override protected String getActionPrefix(){ return "ai.neurophone.widget."; } + + @Override protected void renderWidget(RemoteViews v, String stateJson, int id) { + // parse stateJson (flat JSON), populate the RemoteViews + v.setTextViewText(R.id.widget_state, field(stateJson, "state")); + v.setProgressBar(R.id.widget_salience, 100, intField(stateJson, "salience"), false); + } +} +---- + +=== GossamerActivity (existing, extended) + +`io.gossamer.GossamerActivity` is the existing WebView host, extended for #71 +with an intent hook. Native methods: `nativeInit(Activity, WebView, w, h)`, +`nativeDestroy()`, and the new `nativeIntentReceived(Intent)`. + +`final onNewIntent` records the intent and calls the new +`onIntentReceived(Intent)` hook, whose default forwards the intent to +`nativeIntentReceived`. A subclass overrides `getInitialUrl()`/`getInitialHtml()` +for content and may override `onIntentReceived(Intent)` to pre-process (calling +`super` to keep native delivery). + +== App-callback registration ABI + +The app's pure core supplies behaviour by registering process-global callbacks +*at `JNI_OnLoad` / core init*, before any component fires. The app never touches +JNI; it registers C-ABI function pointers that gossamer's native host invokes +from the relevant lifecycle method. + +[cols="2,3"] +|=== +| `gossamer_android_register_service_callbacks(...)` | service create / start / destroy / sensor-event callbacks (drives `GossamerForegroundService`) +| `gossamer_android_register_widget_callbacks(...)` | fetch-state (returns JSON) + handle-action callbacks (drives `GossamerAppWidgetProvider`) +| `gossamer_android_register_boot_callback(...)` | should-restart decision (drives `GossamerBootReceiver`) +| `gossamer_android_register_intent_callback(...)` | intent-received callback (drives `GossamerActivity.onIntentReceived`) +|=== + +Registration is process-global (one registry per process, not per webview) so a +callback is in scope even when a boot broadcast fires with no Activity alive. +The Idris2 ABI continues to declare the binding surface and its safe wrappers in +`Gossamer.ABI.AndroidComponents`; the registry is the subclass-model successor to +the earlier `gossamer_{service,receiver,widget}_bind` entry points. + +== JSON conventions + +Config and widget state cross the JNI boundary as *JSON strings* (not the old +tab-separated directive records). JSON is a single, well-understood escaping +discipline on both sides and lets an app evolve its config/state schema without +extending a bespoke grammar. + +* *Service config* — `getNativeConfig()` returns a JSON object handed to + `nativeServiceCreate`. The schema is *app-defined and documented by the app*; + gossamer treats it as an opaque string and the default is `{}` (empty object, + meaning "no config"). Example: ++ +[source,json] +---- +{"sampleRateHz":50,"model":"salience-v3","wakeOnHighSalience":true} +---- + +* *Widget state* — `nativeFetchWidgetState(Context)` returns a JSON object the + subclass parses in `renderWidget`. Again app-defined; gossamer only guarantees + it is a JSON string (or `null`, treated as "no state"). Example: ++ +[source,json] +---- +{"state":"Running","salience":42,"lastEventMs":1730000000000} +---- + +* *Sensors* are the exception: they do *not* go through JSON. A subscribed sensor + sample is forwarded as primitives — `nativeSensorEvent(handle, sensorType, + float[] values, timestampNs, accuracy)` — to avoid per-sample allocation and + JSON encoding on a hot path. The subscription set itself is a primitive + `int[] getSubscribedSensors()` of `Sensor.TYPE_*` values. + +[[linearity]] +== Lifecycles and linearity + +`Gossamer.ABI.AndroidComponents` (Idris2, `idris2 --typecheck`-gated) models each +component as a state machine with a terminal teardown state, mirroring +`WindowStateMachine`'s `Closed`. The subclass model does not change this module; +the base classes are its runtime realisation (the JVM drives the same +transitions, the native handle is the same linear resource). + +[cols="1,3,1"] +|=== +| Component | States | Terminal +| Service | `SvcCreated → SvcStarted* → SvcDestroyed` | `SvcDestroyed` +| Receiver | `RcvLive → RcvComplete` (one-shot) | `RcvComplete` +| Widget | `WdgEnabled → WdgDisabled` (update = borrow)| `WdgDisabled` +|=== + +Proved (constructively, zero `believe_me`): + +* *Terminal teardown* — no transition leaves the teardown state + (`svcDestroyedTerminal`, `rcvCompleteTerminal`, `wdgDisabledTerminal`). The + runtime witness is the `final onDestroy` / `final onReceive`: the JVM stops + calling in after teardown and `nativeServiceDestroy` consumes the handle once. +* *Dispatch only while live* — `SvcLive`/`WidgetUpdateBorrow` are uninhabited in + the terminal state, the type-level analogue of the runtime plugin-liveness + check that prevents use-after-free in the IPC dispatcher. After + `nativeServiceDestroy`, no further native call carries a live `ServiceHandle`. +* *Service as a `LinearHandle`* — the long-lived `Service` is a `LinearHandle` + over the *same* Allocated/Active/Consumed machine as the webview + (`LinearService`, `allocateService`, `consumeForStop`). `nativeServiceCreate` + is the single allocate (Allocated); `nativeServiceDestroy` is the single + consume (Consumed). A Service that is leaked (never `onDestroy`) or torn down + twice does not type-check. `final` lifecycle methods are what make the runtime + faithful to that proof: an app cannot inject an extra create or skip the + destroy. + +[NOTE] +==== +*Open question (gossamer issue #69):* the deeper region-calculus modelling — how +a long-lived, JVM-owned Service region nests with the shorter-lived webview +region, and how cross-region references (a Service pushing a Widget update) are +tracked under Ephapax — is *not* settled here. This document and +`AndroidComponents.idr` prove the unambiguous parts; the region-nesting design is +deferred to #69 rather than rushed into a speculative refactor. +==== + +[[ready]] +== "Ready-to-depend-on" checklist + +What must be true for neurophone (neurophone#97) to ship a production Android app +on gossamer-Android under the #71 subclass model. + +=== Build tooling +* [x] JNI binding is real (`jni.zig`) — the shell can link against the NDK. +* [x] Native service host (`services_android.zig`) reuses `jni.zig`; no second + JNI binding. +* [x] Platform dispatch selects the Android backend by `abi == .android` + (previously it fell through to GTK). +* [x] `build.zig` links the NDK (`log`, `android`) and skips GTK for Android. +* [x] Four base classes shipped as a library module + (`android/gossamer-android-services/`); apps depend on it and subclass. +* [ ] CI builds `libgossamer.so` for each ABI on an NDK runner + emulator smoke + test *(deferred: issue #67)*. +* [ ] APK/AAB assembly + signing pipeline *(deferred: issue #68)*. + +=== JNI calling convention +* [x] `...A`-variant calls; cached `JavaVM` for thread attach; global refs. +* [x] Base-class `native` ↔ Zig `export fn` symbol parity, mangled + `Java_io_gossamer_services_*` / `Java_io_gossamer_GossamerActivity_*` (checked). +* [x] App-callback registration ABI + (`gossamer_android_register_{service,widget,boot,intent}_callback(s)`). + +=== Packaging / signing *(deferred — issue #68)* +* [ ] `aapt2` resource compile, `d8`/`r8` dex, `apksigner` v2/v3 (and AAB). +* [x] `minSdk` 26, `targetSdk` 34 (matches neurophone). +* [x] per-ABI `jniLibs/` layout: `src/main/jniLibs//libgossamer.so`. + +=== ABI targets +* [x] `aarch64-linux-android` (arm64-v8a), `x86_64-linux-android` (emulator, + x86_64), `arm-linux-androideabi` (armeabi-v7a) routed through the Android + backend. + +=== Subclass + native-callback invocation paths +* [x] From the webview: JS `postMessage` → `nativePostMessage` → IPC binding. +* [x] From the Service: `final` lifecycle → `nativeServiceCreate/StartCommand/Destroy` + → registered service callbacks; sensors → `nativeSensorEvent`. +* [x] From the Receiver: `final onReceive` → `nativeShouldRestart` → registered + boot callback. +* [x] From the Widget: `final onUpdate`/action → `nativeFetchWidgetState` / + `nativeHandleWidgetAction` → registered widget callbacks; `renderWidget` + assembles the `RemoteViews` JVM-side. +* [x] From the Activity: `final onNewIntent` → `onIntentReceived` → + `nativeIntentReceived` → registered intent callback. +* [ ] On-device validation of all paths on an emulator *(deferred: issue #67)*. + +== Mapping neurophone#97 + +[cols="2,2,1"] +|=== +| neurophone (Kotlin, banned) | gossamer #71 | how +| `MainActivity` | extends `io.gossamer.GossamerActivity` | override `getInitialUrl()`/`onIntentReceived(...)` +| `NeurophoneService` | `NeurophoneRuntimeService extends GossamerForegroundService` | implement notification/channel hooks; register service callbacks +| `BootReceiver` | `NeurophoneBootReceiver extends GossamerBootReceiver` | implement `getServiceClass()`; register boot callback +| `NeurophoneAppWidget` | `NeurophoneAppWidget extends GossamerAppWidgetProvider` | implement layout/prefix/`renderWidget`; register widget callbacks +| `NativeLib` (Rust/JNI) | keep `neurophone-core` Rust | drop the bespoke `external fun`s; register gossamer callbacks at `JNI_OnLoad` +|=== + +The sensor loop, salience computation and LLM calls stay in neurophone's Rust +core; only the JVM glue is replaced by gossamer's four base classes plus a +handful of lines of subclass and one callback-registration call. + +== Deferred work (gossamer issues) + +[cols="1,4"] +|=== +| #67 | NDK CI: cross-compile `libgossamer.so` per ABI on an NDK runner and run an emulator smoke test of all four invocation paths. +| #68 | APK/AAB signing: `aapt2`/`d8`/`r8`/`apksigner` (v2/v3) assembly and a reproducible signing pipeline. +| #69 | Region-calculus of long-lived Service handles: how a JVM-owned Service region nests with the webview region and how cross-region references (Service → Widget update) are tracked under Ephapax. +|=== diff --git a/generated/README.adoc b/generated/README.adoc index 887fc3d..1cd493b 100644 --- a/generated/README.adoc +++ b/generated/README.adoc @@ -1,4 +1,7 @@ // SPDX-License-Identifier: MPL-2.0 = Generated Auto-generated artefacts produced by tooling during the build or verification pipeline — do not edit by hand. + `tlaiser/` contains TLA+ model-checking specs generated for gossamer's capability-token and window-lifecycle state machines. + +The Android JVM-bytecode shims are generated by `tools/android-codegen` (`just android-gen`) into `android/gossamer-generated/` — that location (not this tree) so they fall under the estate's `android/**/src/**/*.java` platform-shim carve-out. See `android/gossamer-generated/README.adoc`. diff --git a/gossamer-abi.ipkg b/gossamer-abi.ipkg index 4bbe62e..4abaaca 100644 --- a/gossamer-abi.ipkg +++ b/gossamer-abi.ipkg @@ -37,6 +37,7 @@ modules = Gossamer.ABI.Types , Gossamer.ABI.IPCIntegrity , Gossamer.ABI.PanelIsolation , Gossamer.ABI.ResourceCleanup + , Gossamer.ABI.AndroidComponents -- All formerly-deferred ABI proof modules from gossamer#22's OWED list -- are now wired and `idris2 --typecheck` green: diff --git a/src/interface/Gossamer/ABI/AndroidComponents.idr b/src/interface/Gossamer/ABI/AndroidComponents.idr new file mode 120000 index 0000000..d3c5817 --- /dev/null +++ b/src/interface/Gossamer/ABI/AndroidComponents.idr @@ -0,0 +1 @@ +../../abi/AndroidComponents.idr \ No newline at end of file diff --git a/src/interface/abi/AndroidComponents.idr b/src/interface/abi/AndroidComponents.idr new file mode 100644 index 0000000..d707342 --- /dev/null +++ b/src/interface/abi/AndroidComponents.idr @@ -0,0 +1,293 @@ +-- SPDX-License-Identifier: MPL-2.0 +-- Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +||| Android Non-UI Component Lifecycles for Gossamer (GS-ANDROID) +||| +||| Gossamer hosts a WebView in an Activity, but a production Android app also +||| needs background components with no webview in scope: a foreground Service, +||| a BroadcastReceiver, and an AppWidgetProvider. This module gives those +||| components the same formal treatment the webview already enjoys: +||| +||| 1. Each component is a state machine with a TERMINAL teardown state and +||| no transition out of it (mirrors WindowStateMachine's `Closed`). +||| 2. Event dispatch is only well-typed while the component is LIVE — the +||| type-level analogue of the runtime plugin-liveness check that prevents +||| use-after-free in the IPC dispatcher. +||| 3. The long-lived Service is modelled as a `LinearHandle` over the SAME +||| lifecycle framework as the webview (HandleLinearity), so the linear +||| "consume exactly once" guarantee crosses the new JNI boundary intact. +||| +||| The native side of these contracts lives in src/interface/ffi/src/ +||| android_{service,receiver,widget}.zig; the JVM side is gossamer-GENERATED +||| Java (android/generated/). An app binds pure handlers via the +||| gossamer_{service,receiver,widget}_bind FFI declared at the foot of this +||| module and never touches JNI itself. +||| +||| OPEN (tracked as a gossamer issue): the deep region-calculus question of how +||| a long-lived, JVM-owned Service handle relates to the shorter-lived webview +||| region — and how cross-region references (a Service pushing a Widget update) +||| are tracked — is NOT settled here. This module proves the parts that are +||| unambiguous (terminal teardown, dispatch-only-while-live, single-consume) +||| and leaves the region-nesting design to that issue. +||| +||| No unsafe escape hatches; every proof is constructive. + +module Gossamer.ABI.AndroidComponents + +import Gossamer.ABI.Types +import Gossamer.ABI.HandleLinearity +import Data.So + +%default total + +-------------------------------------------------------------------------------- +-- Component Taxonomy +-------------------------------------------------------------------------------- + +||| The three non-UI Android components Gossamer hosts natively. +public export +data ComponentKind = ServiceC | ReceiverC | WidgetC + +||| Whether a component owns a LONG-LIVED handle (one whose lifetime spans many +||| dispatches) versus a TRANSIENT one (constructed per dispatch by the JVM). +||| +||| Only the Service owns a long-lived handle: the JVM keeps the Service object +||| alive across many onStartCommand calls. A BroadcastReceiver and an +||| AppWidgetProvider update are constructed, called once, and discarded — each +||| dispatch is a scoped borrow, not an owned handle. +public export +data LongLived : ComponentKind -> Type where + ServiceIsLongLived : LongLived ServiceC + +||| Receiver dispatch is transient (no owned long-lived handle). +public export +data Transient : ComponentKind -> Type where + ReceiverIsTransient : Transient ReceiverC + WidgetIsTransient : Transient WidgetC + +||| Long-lived and transient are disjoint classifications. +public export +longLivedNotTransient : LongLived k -> Transient k -> Void +longLivedNotTransient ServiceIsLongLived x = case x of {} + +-------------------------------------------------------------------------------- +-- Foreground Service lifecycle: Created -> Started* -> Destroyed +-------------------------------------------------------------------------------- + +||| States of a gossamer-hosted foreground Service. +||| Mirrors android.app.Service: onCreate, onStartCommand (repeatable), +||| onDestroy (terminal). +public export +data SvcState = SvcCreated | SvcStarted | SvcDestroyed + +||| Service lifecycle operations (the JNI entry points in services_android.zig). +public export +data SvcOp = OnCreate | OnStartCommand | OnDestroy + +||| Valid Service transitions. No constructor has source SvcDestroyed, so +||| SvcDestroyed is absorbing. onStartCommand may fire repeatedly (START_STICKY +||| redelivery), modelled by the Started -> Started self-loop. +public export +data SvcTransition : (s : SvcState) -> (op : SvcOp) -> (t : SvcState) -> Type where + StartFromCreated : SvcTransition SvcCreated OnStartCommand SvcStarted + StartFromStarted : SvcTransition SvcStarted OnStartCommand SvcStarted + DestroyFromCreated : SvcTransition SvcCreated OnDestroy SvcDestroyed + DestroyFromStarted : SvcTransition SvcStarted OnDestroy SvcDestroyed + +||| GS-ANDROID-INV-1: onDestroy is terminal — no transition leaves SvcDestroyed. +public export +svcDestroyedTerminal : SvcTransition SvcDestroyed op t -> Void +svcDestroyedTerminal _ impossible + +||| A Service is "live" (safe to dispatch events into) iff it is not destroyed. +public export +data SvcLive : SvcState -> Type where + LiveCreated : SvcLive SvcCreated + LiveStarted : SvcLive SvcStarted + +||| A destroyed Service is not live (no dispatch after teardown). +public export +svcDestroyedNotLive : SvcLive SvcDestroyed -> Void +svcDestroyedNotLive x = case x of {} + +||| onStartCommand always lands in the Started state — the foreground work is +||| running regardless of whether this was the first start or a redelivery. +public export +startCommandStarts : SvcTransition s OnStartCommand t -> t = SvcStarted +startCommandStarts StartFromCreated = Refl +startCommandStarts StartFromStarted = Refl + +||| onDestroy always lands in the terminal state. +public export +destroyDestroys : SvcTransition s OnDestroy t -> t = SvcDestroyed +destroyDestroys DestroyFromCreated = Refl +destroyDestroys DestroyFromStarted = Refl + +-------------------------------------------------------------------------------- +-- BroadcastReceiver lifecycle: Live -> Complete (one-shot) +-------------------------------------------------------------------------------- + +||| States of a single BroadcastReceiver invocation. The JVM constructs the +||| receiver, calls onReceive exactly once, and discards it; onReceive must +||| complete within its window (Android tears the receiver down on return). +public export +data RcvState = RcvLive | RcvComplete + +||| The single valid receiver transition: handle the broadcast, then complete. +public export +data RcvTransition : (s : RcvState) -> (t : RcvState) -> Type where + ReceiveOnce : RcvTransition RcvLive RcvComplete + +||| GS-ANDROID-INV-2: a completed receiver is terminal — onReceive cannot fire +||| twice on the same instance. +public export +rcvCompleteTerminal : RcvTransition RcvComplete t -> Void +rcvCompleteTerminal _ impossible + +-------------------------------------------------------------------------------- +-- AppWidgetProvider lifecycle: Enabled -> Disabled (updates are borrows) +-------------------------------------------------------------------------------- + +||| States of a gossamer-hosted home-screen widget provider. onEnabled fires +||| when the first instance is placed; onDisabled when the last is removed +||| (terminal). onUpdate is a BORROW: it renders without changing provider +||| state, exactly like the webview's loadHTML/navigate borrows. +public export +data WdgState = WdgEnabled | WdgDisabled + +public export +data WdgOp = WidgetOnUpdate | WidgetOnDisabled + +||| Valid widget transitions. onUpdate is absent here because, as a borrow, it +||| does not move the provider between states (see `widgetUpdateBorrow`). +public export +data WdgTransition : (s : WdgState) -> (op : WdgOp) -> (t : WdgState) -> Type where + DisableFromEnabled : WdgTransition WdgEnabled WidgetOnDisabled WdgDisabled + +||| GS-ANDROID-INV-3: onDisabled is terminal. +public export +wdgDisabledTerminal : WdgTransition WdgDisabled op t -> Void +wdgDisabledTerminal _ impossible + +||| onUpdate is a borrow: it is only valid on an enabled provider and leaves the +||| state unchanged. Encoded as a predicate rather than a state transition. +public export +data WidgetUpdateBorrow : WdgState -> Type where + UpdateWhileEnabled : WidgetUpdateBorrow WdgEnabled + +||| A disabled provider cannot service updates. +public export +wdgDisabledNoUpdate : WidgetUpdateBorrow WdgDisabled -> Void +wdgDisabledNoUpdate x = case x of {} + +-------------------------------------------------------------------------------- +-- Service as a Linear Handle (linearity preserved across the JNI boundary) +-------------------------------------------------------------------------------- + +||| Opaque handle to a gossamer-hosted foreground Service. +||| Like WebviewHandle, this is a LINEAR resource carrying a non-null proof: +||| it is allocated once (onCreate) and consumed once (onDestroy). +public export +data ServiceHandle : Type where + MkService : (ptr : Bits64) + -> {auto 0 nonNull : So (ptr /= 0)} + -> ServiceHandle + +||| Extract the raw pointer (for FFI calls). +public export +servicePtr : ServiceHandle -> Bits64 +servicePtr (MkService ptr) = ptr + +||| Recover the erased non-null witness as a ValidToken, mirroring +||| HandleLinearity.webviewValid. The witness already lives inside MkService; +||| this re-exposes it so allocation is total with no runtime null re-check. +public export +serviceValid : (s : ServiceHandle) -> ValidToken (servicePtr s) +serviceValid (MkService ptr {nonNull}) = MkValid {nonNull} + +||| A linearly-tracked Service handle, reusing the generic Allocated/Active/ +||| Consumed machine from HandleLinearity. This is the load-bearing claim that +||| the new Service boundary keeps Gossamer's linear guarantees: a Service that +||| is leaked (never onDestroy) or torn down twice does not type-check. +public export +LinearService : HandleState -> Type +LinearService = LinearHandle ServiceHandle + +||| Allocate a linear Service handle (state Allocated), set up at onCreate. +public export +allocateService : ServiceHandle -> LinearService Allocated +allocateService s = MkLinear s (servicePtr s) {valid = serviceValid s} + +||| Consume the Service handle at onDestroy. Active -> Consumed, returning the +||| raw handle for the final FFI teardown call. There is no way to reconstruct +||| an Active handle from the Consumed one, so no dispatch can follow. +public export +consumeForStop : LinearService Active -> (ServiceHandle, LinearService Consumed) +consumeForStop = consume + +||| The Service lifecycle state maps onto the generic handle lifecycle: +||| Created↔Allocated, Started↔Active, Destroyed↔Consumed. +public export +svcToHandleState : SvcState -> HandleState +svcToHandleState SvcCreated = Allocated +svcToHandleState SvcStarted = Active +svcToHandleState SvcDestroyed = Consumed + +||| The mapping sends the terminal Service state to the terminal handle state, +||| witnessing that "Service destroyed" and "handle consumed" coincide. +public export +destroyedIsConsumed : svcToHandleState SvcDestroyed = Consumed +destroyedIsConsumed = Refl + +-------------------------------------------------------------------------------- +-- FFI: native callback registration (implemented in services_android.zig) +-------------------------------------------------------------------------------- +-- +-- The #71 companion uses the subclass model: the JVM-side base classes +-- (io.gossamer.services.*) own the Android contracts, and the app's native core +-- (Rust/Zig) plugs in by registering plain C callbacks at JNI_OnLoad. gossamer +-- owns every JNI call. Each callback is a raw C function pointer (Bits64); the +-- concrete handler is supplied by the app, so these declarations fix only the C +-- symbol and arity. The foreground-Service handle threaded to the callbacks is +-- the independent ServiceHandle modelled above. + +||| Register the foreground-Service callbacks: create, startCommand, destroy, +||| sensorEvent (four raw C function pointers). +export +%foreign "C:gossamer_android_register_service_callbacks, libgossamer" +prim__registerServiceCallbacks : Bits64 -> Bits64 -> Bits64 -> Bits64 -> PrimIO () + +||| Register the AppWidget callbacks: fetchState, handleAction. +export +%foreign "C:gossamer_android_register_widget_callbacks, libgossamer" +prim__registerWidgetCallbacks : Bits64 -> Bits64 -> PrimIO () + +||| Register the boot-receiver shouldRestart predicate callback. +export +%foreign "C:gossamer_android_register_boot_callback, libgossamer" +prim__registerBootCallback : Bits64 -> PrimIO () + +||| Register the Activity intent callback. +export +%foreign "C:gossamer_android_register_intent_callback, libgossamer" +prim__registerIntentCallback : Bits64 -> PrimIO () + +||| Safe wrapper: register the foreground-Service native callbacks. +export +registerServiceCallbacks : (create : Bits64) -> (start : Bits64) -> (destroy : Bits64) -> (sensor : Bits64) -> IO () +registerServiceCallbacks c s d sn = primIO (prim__registerServiceCallbacks c s d sn) + +||| Safe wrapper: register the AppWidget native callbacks. +export +registerWidgetCallbacks : (fetchState : Bits64) -> (handleAction : Bits64) -> IO () +registerWidgetCallbacks f h = primIO (prim__registerWidgetCallbacks f h) + +||| Safe wrapper: register the boot-receiver callback. +export +registerBootCallback : (shouldRestart : Bits64) -> IO () +registerBootCallback sr = primIO (prim__registerBootCallback sr) + +||| Safe wrapper: register the Activity intent callback. +export +registerIntentCallback : (onIntent : Bits64) -> IO () +registerIntentCallback oi = primIO (prim__registerIntentCallback oi) diff --git a/src/interface/ffi/build.zig b/src/interface/ffi/build.zig index 55e2e07..ee83897 100644 --- a/src/interface/ffi/build.zig +++ b/src/interface/ffi/build.zig @@ -29,7 +29,18 @@ const std = @import("std"); /// Link platform-specific system libraries to a module. -fn linkPlatformLibs(module: *std.Build.Module, os: std.Target.Os.Tag) void { +/// +/// `abi` is consulted first because an Android target reports `os == .linux` +/// (it runs a Linux kernel) yet must NOT link GTK/WebKitGTK — its WebView and +/// component hosts are reached entirely over JNI. The only NDK libraries the +/// shell needs are liblog (diagnostics) and libandroid; libc is linked by the +/// module itself. +fn linkPlatformLibs(module: *std.Build.Module, os: std.Target.Os.Tag, abi: std.Target.Abi) void { + if (abi == .android) { + module.linkSystemLibrary("log", .{}); + module.linkSystemLibrary("android", .{}); + return; + } switch (os) { .linux, .freebsd, .openbsd, .netbsd => { // GTK 3 + WebKitGTK 4.1 (same across Linux and BSD) @@ -64,6 +75,7 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const os = target.query.os_tag orelse builtin.os.tag; + const abi = target.result.abi; // Create the root module (shared between shared/static/test) const root_module = b.createModule(.{ @@ -73,7 +85,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - linkPlatformLibs(root_module, os); + linkPlatformLibs(root_module, os, abi); // --- Shared library (.so / .dylib / .dll) --- const shared_lib = b.addLibrary(.{ @@ -93,7 +105,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - linkPlatformLibs(static_module, os); + linkPlatformLibs(static_module, os, abi); const static_lib = b.addLibrary(.{ .name = "gossamer", @@ -111,7 +123,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - linkPlatformLibs(test_module, os); + linkPlatformLibs(test_module, os, abi); const unit_tests = b.addTest(.{ .root_module = test_module, @@ -136,7 +148,7 @@ pub fn build(b: *std.Build) void { .link_libc = true, }); - linkPlatformLibs(gossamer_module, os); + linkPlatformLibs(gossamer_module, os, abi); const display_test_module = b.createModule(.{ .root_source_file = b.path("test/display_test.zig"), @@ -148,7 +160,7 @@ pub fn build(b: *std.Build) void { }, }); - linkPlatformLibs(display_test_module, os); + linkPlatformLibs(display_test_module, os, abi); const display_tests = b.addTest(.{ .root_module = display_test_module, @@ -157,6 +169,43 @@ pub fn build(b: *std.Build) void { const run_display_tests = b.addRunArtifact(display_tests); const display_test_step = b.step("test-display", "Run Gossamer display integration tests (requires X11/Wayland/Xvfb)"); display_test_step.dependOn(&run_display_tests.step); + + // --- Android component host logic tests (host-runnable; pure Zig, no NDK) --- + // The JNI binding and the Service/Receiver/Widget hosts are pure Zig, so + // their registry/dispatch/JSON/directive logic runs on the host via + // `zig build test-android`. This is a SEPARATE step (not folded into the + // default `test`): the estate `test` gate runs under `2>/dev/null`, which + // hides Zig compile errors, so a dedicated workflow (.github/workflows/ + // android.yml) runs this step with visible output instead. + // Rooted in src/ (not test/) because Zig 0.15 forbids importing files + // outside a module's root directory — a test/ root cannot @import("../src/ + // jni.zig"). The aggregator pulls in the android sources from the same dir. + const android_test_module = b.createModule(.{ + .root_source_file = b.path("src/android_test.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + }); + // Deliberately NO linkPlatformLibs: these modules pull in neither GTK nor + // the NDK; keeping them library-free is what makes the tests host-runnable. + + const android_tests = b.addTest(.{ + .root_module = android_test_module, + }); + + const run_android_tests = b.addRunArtifact(android_tests); + const android_test_step = b.step("test-android", "Run Android component host logic tests (host-runnable, no NDK)"); + android_test_step.dependOn(&run_android_tests.step); + + // --- Android cross-compilation (requires the Android NDK) --- + // Produces libgossamer.so for each ABI neurophone (and other downstreams) + // ship. This is a thin wrapper over the standard target options; the + // per-ABI loop and jniLibs packaging live in the Justfile (`just + // android-build`). Selected purely so `zig build -Dtarget=-linux-android` + // routes through the JNI WebView backend and the component hosts. + // zig build -Dtarget=aarch64-linux-android # arm64-v8a + // zig build -Dtarget=x86_64-linux-android # x86_64 (emulator) + // zig build -Dtarget=arm-linux-androideabi # armeabi-v7a } const builtin = @import("builtin"); diff --git a/src/interface/ffi/src/android_test.zig b/src/interface/ffi/src/android_test.zig new file mode 100644 index 0000000..76348e2 --- /dev/null +++ b/src/interface/ffi/src/android_test.zig @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Gossamer — Android native test aggregator +// +// The JNI binding (jni.zig) and the service/receiver/widget native host +// (services_android.zig) are pure Zig with no Android headers, so their +// handle/registry/JSON logic is HOST-RUNNABLE. This aggregator pulls those +// files' `test` blocks into `zig build test-android`, so the #71 companion +// contract is exercised on a normal CI runner — no phone, no NDK. +// +// It lives in src/ (not test/) on purpose: Zig 0.15 forbids a module from +// importing files outside its own root directory, so a test/ aggregator cannot +// `@import("../src/jni.zig")`. Rooting here keeps every import same-directory. +// Nothing in the library build graph reaches this file (main.zig never imports +// it), so it is compiled only by the `test-android` step. +// +// What is NOT covered: the actual JNI calls through a live JNIEnv (those need a +// device/emulator). The `export fn Java_io_gossamer_*` entry points compile in +// this binary but are never invoked — they are validated on-device. + +test { + _ = @import("jni.zig"); + _ = @import("services_android.zig"); +} diff --git a/src/interface/ffi/src/jni.zig b/src/interface/ffi/src/jni.zig new file mode 100644 index 0000000..40d79d5 --- /dev/null +++ b/src/interface/ffi/src/jni.zig @@ -0,0 +1,427 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Gossamer — Real JNI binding (pure Zig, no C shim) +// +// This module models the Java Native Interface function tables directly in +// Zig and provides typed wrappers. It exists because the previous Android +// bridge declared `extern fn jni_FindClass(...)` and friends — symbols that +// are defined NOWHERE. Real JNI is not a set of flat C symbols: every call is +// an indirect call through the per-thread `JNIEnv` function table +// (`(*env)->FindClass(env, ...)` in C). Without that indirection the Android +// shell cannot link, let alone run. +// +// Design choices that keep this binding correct without a local Android +// toolchain to compile against: +// +// 1. The function table is addressed BY INDEX through the canonical JNI +// ordinal table (stable across every Android API level since 1.6). We do +// not hand-transcribe ~232 struct fields — only the ordinals we use — +// so an off-by-one in an unused slot cannot silently corrupt a used one. +// +// 2. Method invocation uses the `...A` variants (`CallVoidMethodA`, +// `NewObjectA`, `CallStaticVoidMethodA`) which take a `jvalue[]` array +// rather than C varargs. This sidesteps the platform varargs ABI (a real +// hazard when passing a 64-bit `jlong` through `...` on arm64) and is the +// JNI-recommended path for generated/bridged callers. +// +// 3. Pointer-width and union layout follow the Android NDK `jni.h` exactly. +// +// This file is pure Zig and compiles on any target (it pulls in no Android +// headers); it is only LINKED into a working image when building for an +// `*-linux-android` target. That property lets its `test` blocks run on the +// host CI runner. + +const std = @import("std"); + +//============================================================================== +// Opaque JNI reference types +//============================================================================== + +/// `jobject` — opaque reference to any Java object (local or global). +pub const jobject = ?*anyopaque; +/// `jclass` — reference to a `java.lang.Class`. +pub const jclass = jobject; +/// `jstring` — reference to a `java.lang.String`. +pub const jstring = jobject; +/// `jthrowable` — reference to a `java.lang.Throwable`. +pub const jthrowable = jobject; +/// `jmethodID` — opaque method identifier (NOT a GC reference; stable). +pub const jmethodID = ?*anyopaque; +/// `jfieldID` — opaque field identifier. +pub const jfieldID = ?*anyopaque; +/// `jarray` — reference to any Java array (base of the typed array refs). +pub const jarray = jobject; +/// `jfloatArray` — reference to a Java `float[]` (e.g. `SensorEvent.values`). +/// Like every typed array reference in JNI it is just a `jobject`. +pub const jfloatArray = jarray; + +/// JNI primitive scalar types (NDK `jni.h`). +pub const jint = i32; +pub const jlong = i64; +pub const jboolean = u8; +pub const jbyte = i8; +pub const jchar = u16; +pub const jshort = i16; +pub const jfloat = f32; +pub const jdouble = f64; +pub const jsize = jint; + +pub const JNI_TRUE: jboolean = 1; +pub const JNI_FALSE: jboolean = 0; + +/// JNI status / version constants used by the invocation API. +pub const JNI_OK: jint = 0; +pub const JNI_EDETACHED: jint = -2; +pub const JNI_EVERSION: jint = -3; +pub const JNI_VERSION_1_6: jint = 0x00010006; + +/// `jvalue` — the 8-byte C union passed to the `...A` call variants. +/// Modelled as an `extern union` so its size/alignment match the NDK. +pub const jvalue = extern union { + z: jboolean, + b: jbyte, + c: jchar, + s: jshort, + i: jint, + j: jlong, + f: jfloat, + d: jdouble, + l: jobject, +}; + +/// Construct a `jvalue` carrying an object reference. +pub inline fn vObj(o: jobject) jvalue { + return .{ .l = o }; +} +/// Construct a `jvalue` carrying a 64-bit `long` (e.g. a native pointer). +pub inline fn vLong(x: jlong) jvalue { + return .{ .j = x }; +} +/// Construct a `jvalue` carrying an `int`. +pub inline fn vInt(x: jint) jvalue { + return .{ .i = x }; +} + +//============================================================================== +// Function-table addressing +// +// `JNIEnv` is `const struct JNINativeInterface*`. The struct it points at is a +// flat table of function pointers. We model it as a many-item pointer of +// `*anyopaque` slots and index it by ordinal, casting each used slot to its +// real signature at the call site. +//============================================================================== + +/// The function table itself: an array of opaque function pointers. +pub const FunctionTable = [*]const ?*const anyopaque; + +/// `JNIEnv` — pointer to the function table. One per attached thread. +pub const JNIEnv = *const FunctionTable; + +/// Canonical JNI ordinals (`JNINativeInterface` field order, NDK `jni.h`). +/// Only the entries Gossamer calls are listed; the order is fixed by the spec. +const Ord = struct { + const ExceptionClear: usize = 17; + const NewGlobalRef: usize = 21; + const DeleteGlobalRef: usize = 22; + const NewObjectA: usize = 30; + const GetObjectClass: usize = 31; + const GetMethodID: usize = 33; + const CallObjectMethodA: usize = 36; + const CallVoidMethodA: usize = 63; + const GetStaticMethodID: usize = 113; + const CallStaticVoidMethodA: usize = 143; + const NewStringUTF: usize = 167; + const GetStringUTFChars: usize = 169; + const ReleaseStringUTFChars: usize = 170; + // Array access — needed by the sensor path (Java hands `float[]` values). + // The `GetArrayElements` / `ReleaseArrayElements` blocks are GROUPED BY + // OPERATION then ordered Boolean,Byte,Char,Short,Int,Long,Float,Double, so the + // Float slot sits 6 past the start of its block — NOT adjacent to the Byte + // slot. (Verified field-by-field against the canonical JNINativeInterface_ + // table: Get block starts at 183 → Float = 189; Release block starts at 191 → + // Float = 197. Do not "simplify" these to 184/192: those are the *Byte* + // variants, and reading f32 sensor samples through them corrupts the data.) + const GetArrayLength: usize = 171; + const GetFloatArrayElements: usize = 189; + const ReleaseFloatArrayElements: usize = 197; + const GetJavaVM: usize = 219; + const ExceptionCheck: usize = 228; +}; + +/// Fetch table slot `ord` from `env` and reinterpret it as function type `Fn`. +inline fn slot(env: JNIEnv, comptime ord: usize, comptime Fn: type) Fn { + // SAFETY: the slot holds a real JNI function pointer; reinterpret it as the + // typed signature. `@ptrCast(@alignCast(...))` is the same data->fn pointer + // form std.DynLib.lookup uses for dlsym results. + return @ptrCast(@alignCast(env.*[ord].?)); +} + +//============================================================================== +// Typed wrappers — the only surface the rest of the shell should use +//============================================================================== + +/// `(*env)->FindClass(env, name)` — resolve a class by JNI name +/// (e.g. "android/webkit/WebView"). Returns null + a pending exception on miss. +pub fn findClass(env: JNIEnv, name: [*:0]const u8) jclass { + const FindClass = *const fn (JNIEnv, [*:0]const u8) callconv(.c) jclass; + // FindClass is ordinal 6; resolve directly (kept inline to avoid an unused + // ordinal constant when only a subset of callers need it). + // SAFETY: slot 6 holds the real FindClass function pointer; reinterpret it as + // the typed signature — the same data->fn pointer form as `slot`/std.DynLib. + const f: FindClass = @ptrCast(@alignCast(env.*[6].?)); + return f(env, name); +} + +/// `(*env)->GetObjectClass(env, obj)`. +pub fn getObjectClass(env: JNIEnv, obj: jobject) jclass { + const F = *const fn (JNIEnv, jobject) callconv(.c) jclass; + return slot(env, Ord.GetObjectClass, F)(env, obj); +} + +/// `(*env)->GetMethodID(env, cls, name, sig)` — instance method id. +pub fn getMethodID(env: JNIEnv, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) jmethodID { + const F = *const fn (JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jmethodID; + return slot(env, Ord.GetMethodID, F)(env, cls, name, sig); +} + +/// `(*env)->GetStaticMethodID(env, cls, name, sig)` — static method id. +pub fn getStaticMethodID(env: JNIEnv, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) jmethodID { + const F = *const fn (JNIEnv, jclass, [*:0]const u8, [*:0]const u8) callconv(.c) jmethodID; + return slot(env, Ord.GetStaticMethodID, F)(env, cls, name, sig); +} + +/// `(*env)->NewObjectA(env, cls, ctor, args)` — construct an object. +pub fn newObject(env: JNIEnv, cls: jclass, ctor: jmethodID, args: []const jvalue) jobject { + const F = *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) jobject; + return slot(env, Ord.NewObjectA, F)(env, cls, ctor, args.ptr); +} + +/// `(*env)->CallVoidMethodA(env, obj, mid, args)` — invoke a `void` instance method. +pub fn callVoidMethod(env: JNIEnv, obj: jobject, mid: jmethodID, args: []const jvalue) void { + const F = *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) void; + slot(env, Ord.CallVoidMethodA, F)(env, obj, mid, args.ptr); +} + +/// `(*env)->CallObjectMethodA(env, obj, mid, args)` — invoke an Object-returning method. +pub fn callObjectMethod(env: JNIEnv, obj: jobject, mid: jmethodID, args: []const jvalue) jobject { + const F = *const fn (JNIEnv, jobject, jmethodID, [*]const jvalue) callconv(.c) jobject; + return slot(env, Ord.CallObjectMethodA, F)(env, obj, mid, args.ptr); +} + +/// `(*env)->CallStaticVoidMethodA(env, cls, mid, args)`. +pub fn callStaticVoidMethod(env: JNIEnv, cls: jclass, mid: jmethodID, args: []const jvalue) void { + const F = *const fn (JNIEnv, jclass, jmethodID, [*]const jvalue) callconv(.c) void; + slot(env, Ord.CallStaticVoidMethodA, F)(env, cls, mid, args.ptr); +} + +/// `(*env)->NewStringUTF(env, bytes)` — make a Java String from modified-UTF-8. +pub fn newStringUTF(env: JNIEnv, bytes: [*:0]const u8) jstring { + const F = *const fn (JNIEnv, [*:0]const u8) callconv(.c) jstring; + return slot(env, Ord.NewStringUTF, F)(env, bytes); +} + +/// `(*env)->GetStringUTFChars(env, s, isCopy)` — borrow the UTF-8 bytes. +/// Caller MUST pair every successful call with `releaseStringUTFChars`. +pub fn getStringUTFChars(env: JNIEnv, s: jstring) ?[*:0]const u8 { + const F = *const fn (JNIEnv, jstring, ?*jboolean) callconv(.c) ?[*:0]const u8; + return slot(env, Ord.GetStringUTFChars, F)(env, s, null); +} + +/// `(*env)->ReleaseStringUTFChars(env, s, chars)`. +pub fn releaseStringUTFChars(env: JNIEnv, s: jstring, chars: [*:0]const u8) void { + const F = *const fn (JNIEnv, jstring, [*:0]const u8) callconv(.c) void; + slot(env, Ord.ReleaseStringUTFChars, F)(env, s, chars); +} + +/// Release mode for the `ReleaseArrayElements` calls. +/// `JNI_ABORT` frees the carrier buffer WITHOUT copying changes back — the +/// correct, cheapest choice when native only read the array (the sensor path). +pub const JNI_COMMIT: jint = 1; +pub const JNI_ABORT: jint = 2; + +/// `(*env)->GetArrayLength(env, array)` — element count of any Java array. +pub fn getArrayLength(env: JNIEnv, array: jarray) jsize { + const F = *const fn (JNIEnv, jarray) callconv(.c) jsize; + return slot(env, Ord.GetArrayLength, F)(env, array); +} + +/// `(*env)->GetFloatArrayElements(env, array, isCopy)` — borrow a `float[]`'s +/// backing store as a C pointer. Caller MUST pair every successful (non-null) +/// call with `releaseFloatArrayElements`. `isCopy` is passed null (unused here). +pub fn getFloatArrayElements(env: JNIEnv, array: jfloatArray) ?[*]jfloat { + const F = *const fn (JNIEnv, jfloatArray, ?*jboolean) callconv(.c) ?[*]jfloat; + return slot(env, Ord.GetFloatArrayElements, F)(env, array, null); +} + +/// `(*env)->ReleaseFloatArrayElements(env, array, elems, mode)`. +/// Defaults the read-only sensor path to `JNI_ABORT` (no copy-back). +pub fn releaseFloatArrayElements(env: JNIEnv, array: jfloatArray, elems: [*]jfloat, mode: jint) void { + const F = *const fn (JNIEnv, jfloatArray, [*]jfloat, jint) callconv(.c) void; + slot(env, Ord.ReleaseFloatArrayElements, F)(env, array, elems, mode); +} + +/// `(*env)->NewGlobalRef(env, o)` — promote a local ref to a process-global ref. +pub fn newGlobalRef(env: JNIEnv, o: jobject) jobject { + const F = *const fn (JNIEnv, jobject) callconv(.c) jobject; + return slot(env, Ord.NewGlobalRef, F)(env, o); +} + +/// `(*env)->DeleteGlobalRef(env, o)`. +pub fn deleteGlobalRef(env: JNIEnv, o: jobject) void { + const F = *const fn (JNIEnv, jobject) callconv(.c) void; + slot(env, Ord.DeleteGlobalRef, F)(env, o); +} + +/// `(*env)->ExceptionCheck(env)` — true if a Java exception is pending. +pub fn exceptionCheck(env: JNIEnv) bool { + const F = *const fn (JNIEnv) callconv(.c) jboolean; + return slot(env, Ord.ExceptionCheck, F)(env) != JNI_FALSE; +} + +/// `(*env)->ExceptionClear(env)` — discard any pending Java exception. +pub fn exceptionClear(env: JNIEnv) void { + const F = *const fn (JNIEnv) callconv(.c) void; + slot(env, Ord.ExceptionClear, F)(env); +} + +/// Clear a pending exception if one is present; return whether one was found. +/// Bridged callbacks must not return to the JVM with an exception still set. +pub fn clearPendingException(env: JNIEnv) bool { + if (exceptionCheck(env)) { + exceptionClear(env); + return true; + } + return false; +} + +//============================================================================== +// Invocation API (JavaVM) — needed to attach the Service/Receiver/Widget +// threads, which the JVM may invoke on threads that have no JNIEnv yet. +//============================================================================== + +/// The invocation function table (`JNIInvokeInterface`). +pub const InvokeTable = [*]const ?*const anyopaque; +/// `JavaVM` — pointer to the invocation table. One per process. +pub const JavaVM = *const InvokeTable; + +const InvokeOrd = struct { + const AttachCurrentThread: usize = 4; + const DetachCurrentThread: usize = 5; + const GetEnv: usize = 6; +}; + +inline fn vmSlot(vm: JavaVM, comptime ord: usize, comptime Fn: type) Fn { + // SAFETY: the JNIInvokeInterface slot holds a real function pointer; + // reinterpret it as the typed signature — the JavaVM analogue of `slot`, + // the same data->fn pointer form std.DynLib.lookup uses for dlsym results. + return @ptrCast(@alignCast(vm.*[ord].?)); +} + +/// `(*env)->GetJavaVM(env, &vm)` — recover the process `JavaVM` from any env. +pub fn getJavaVM(env: JNIEnv) ?JavaVM { + const F = *const fn (JNIEnv, *?JavaVM) callconv(.c) jint; + var vm: ?JavaVM = null; + const rc = slot(env, Ord.GetJavaVM, F)(env, &vm); + if (rc != JNI_OK) return null; + return vm; +} + +/// `(*vm)->GetEnv(vm, &env, version)` — fetch this thread's env if attached. +pub fn getEnv(vm: JavaVM, version: jint) ?JNIEnv { + const F = *const fn (JavaVM, *?*anyopaque, jint) callconv(.c) jint; + var env: ?*anyopaque = null; + const rc = vmSlot(vm, InvokeOrd.GetEnv, F)(vm, &env, version); + if (rc != JNI_OK) return null; + const e = env orelse return null; + // SAFETY: GetEnv wrote a real JNIEnv* into `e`; reinterpret it as our env + // pointer type (cast to the non-optional target, then coerce to ?JNIEnv). + const j: JNIEnv = @ptrCast(@alignCast(e)); + return j; +} + +/// `(*vm)->AttachCurrentThread(vm, &env, null)` — attach a native thread so it +/// can make JNI calls. Returns the freshly-bound env, or null on failure. +pub fn attachCurrentThread(vm: JavaVM) ?JNIEnv { + const F = *const fn (JavaVM, *?*anyopaque, ?*anyopaque) callconv(.c) jint; + var env: ?*anyopaque = null; + const rc = vmSlot(vm, InvokeOrd.AttachCurrentThread, F)(vm, &env, null); + if (rc != JNI_OK) return null; + const e = env orelse return null; + // SAFETY: AttachCurrentThread wrote a real JNIEnv* into `e`. + const j: JNIEnv = @ptrCast(@alignCast(e)); + return j; +} + +/// `(*vm)->DetachCurrentThread(vm)` — MUST be called before a thread that +/// attached itself exits, or the JVM will abort. +pub fn detachCurrentThread(vm: JavaVM) void { + const F = *const fn (JavaVM) callconv(.c) jint; + _ = vmSlot(vm, InvokeOrd.DetachCurrentThread, F)(vm); +} + +//============================================================================== +// Tests (host-runnable — no Android required) +//============================================================================== + +test "jvalue is the 8-byte JNI union" { + // The NDK defines jvalue as an 8-byte union; the ...A call variants index + // it as a contiguous array, so size and alignment must be exactly 8. + try std.testing.expectEqual(@as(usize, 8), @sizeOf(jvalue)); + try std.testing.expectEqual(@as(usize, 8), @alignOf(jvalue)); +} + +test "jvalue constructors select the right union member" { + try std.testing.expectEqual(@as(jlong, 0x0123_4567_89AB_CDEF), vLong(0x0123_4567_89AB_CDEF).j); + try std.testing.expectEqual(@as(jint, -7), vInt(-7).i); + try std.testing.expect(vObj(null).l == null); +} + +test "ordinals are monotonic and within the JNI table" { + // The JNINativeInterface table has 233 entries (0..232). A used ordinal + // landing outside that range would mean a transcription error. + const ords = [_]usize{ + Ord.ExceptionClear, Ord.NewGlobalRef, Ord.DeleteGlobalRef, + Ord.NewObjectA, Ord.GetObjectClass, Ord.GetMethodID, + Ord.CallObjectMethodA, Ord.CallVoidMethodA, Ord.GetStaticMethodID, + Ord.CallStaticVoidMethodA, Ord.NewStringUTF, Ord.GetStringUTFChars, + Ord.ReleaseStringUTFChars, Ord.GetArrayLength, Ord.GetFloatArrayElements, + Ord.ReleaseFloatArrayElements, Ord.GetJavaVM, Ord.ExceptionCheck, + }; + for (ords) |o| try std.testing.expect(o <= 232); + // A couple of fixed relationships from the spec (…Method / …MethodV / …MethodA + // are consecutive triples), used here as a transcription self-check. + try std.testing.expectEqual(Ord.CallVoidMethodA, @as(usize, 63)); + try std.testing.expectEqual(Ord.CallStaticVoidMethodA, @as(usize, 143)); + // The typed-array Get/Release blocks are 8-wide and Float is the 7th entry + // (index 6). Pin Float exactly so it can never silently drift onto the Byte + // slot (a -5 error that still type-checks but corrupts sensor reads). + try std.testing.expectEqual(Ord.GetArrayLength, @as(usize, 171)); + try std.testing.expectEqual(Ord.GetFloatArrayElements, @as(usize, 189)); + try std.testing.expectEqual(Ord.ReleaseFloatArrayElements, @as(usize, 197)); + // Release block sits exactly 8 slots past the Get block (one full T-width). + try std.testing.expectEqual(@as(usize, 8), Ord.ReleaseFloatArrayElements - Ord.GetFloatArrayElements); +} + +test "every JNI wrapper type-checks on the host (compiled, not invoked)" { + // Taking the address of each wrapper forces full semantic analysis, so a + // bad function-pointer cast or signature surfaces on the host CI runner — + // even though webview_android.zig (which uses them) only compiles for an + // *-android target. The wrappers are never CALLED here: there is no live + // JNIEnv on the host. + const refs = .{ + &findClass, &getObjectClass, &getMethodID, + &getStaticMethodID, &newObject, &callVoidMethod, + &callObjectMethod, &callStaticVoidMethod, &newStringUTF, + &getStringUTFChars, &releaseStringUTFChars, &newGlobalRef, + &deleteGlobalRef, &exceptionCheck, &exceptionClear, + &clearPendingException, &getJavaVM, &getEnv, + &attachCurrentThread, &detachCurrentThread, + &getArrayLength, &getFloatArrayElements, &releaseFloatArrayElements, + }; + // Constructing `refs` already takes the address of each wrapper, which + // forces its analysis; the loop just keeps `refs` used. + inline for (refs) |r| { + _ = r; + } +} diff --git a/src/interface/ffi/src/main.zig b/src/interface/ffi/src/main.zig index 2acf51e..dcd1c92 100644 --- a/src/interface/ffi/src/main.zig +++ b/src/interface/ffi/src/main.zig @@ -94,18 +94,36 @@ comptime { _ = @import("conf.zig"); } +// Default IPC channel handlers (gossamer_channel_register_defaults). Holds the +// 28 window/group/transmute/debug/groove/shell-exec handlers the CLI used to +// bind by hand; libgossamer now registers them on demand so both the native +// CLI and the future Ephapax-wasm CLI share one implementation. Without this +// import the export is absent from libgossamer and the CLI fails to link. +comptime { + _ = @import("ipc_handlers.zig"); +} + // Version information — bump on each release const VERSION = "0.3.0"; const BUILD_INFO = "Gossamer " ++ VERSION ++ " built with Zig " ++ @import("builtin").zig_version_string; /// Platform-specific webview implementation. /// Compile-time dispatch — no runtime overhead. -const platform = switch (builtin.os.tag) { +/// +/// Android is detected BEFORE the os-tag switch: an Android target reports +/// `os.tag == .linux` (it is a Linux kernel), so dispatching on os.tag alone +/// wrongly selects the GTK backend and links WebKitGTK into an .so that can +/// never load on a phone. The `abi == .android` guard routes it to the JNI +/// WebView backend instead. The comptime `if` means the android import is not +/// evaluated on non-android targets, so the desktop paths are unchanged. +const platform = if (builtin.abi == .android) + @import("webview_android.zig") +else switch (builtin.os.tag) { .linux, .freebsd, .openbsd, .netbsd => @import("webview_gtk.zig"), .macos => @import("webview_cocoa.zig"), .windows => @import("webview_win32.zig"), .ios => @import("webview_ios.zig"), - else => @compileError("Gossamer: unsupported platform. Supported: linux, BSD, macOS, Windows, iOS. Android requires NDK target."), + else => @compileError("Gossamer: unsupported platform. Supported: Linux, BSD, macOS, Windows, iOS, Android (NDK)."), }; //============================================================================== diff --git a/src/interface/ffi/src/services_android.zig b/src/interface/ffi/src/services_android.zig new file mode 100644 index 0000000..b4eda70 --- /dev/null +++ b/src/interface/ffi/src/services_android.zig @@ -0,0 +1,633 @@ +// SPDX-License-Identifier: MPL-2.0 +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// Gossamer — Android subclass-base "services" host (issue #71, native half) +// +// Native side of the hand-authored `io.gossamer.services.*` base classes +// (GossamerForegroundService / GossamerBootReceiver / GossamerAppWidgetProvider) +// plus the services-variant GossamerActivity.onNewIntent hook. Where the +// earlier directive component layer (now removed) routed every lifecycle event +// through a STRING directive registry, this surface is the lower-level, +// per-instance boundary an app's native core (e.g. neurophone's Rust) drives +// directly: +// +// * A foreground Service is a LINEAR, per-instance resource (created once at +// onCreate, destroyed once at onDestroy — the exact SvcCreated -> SvcStarted* +// -> SvcDestroyed machine modelled in Gossamer.ABI.AndroidComponents). It +// therefore gets its OWN opaque `ServiceHandle`, deliberately INDEPENDENT of +// the webview `GossamerHandle`: a Service can outlive (or exist without) any +// Activity, so coupling the two would be a lifetime bug. The handle is a +// heap pointer the JVM round-trips as a `long`. +// +// * Sensor samples are hot and primitive, so they cross as a raw +// `float[]` + `Sensor.TYPE_*` int (NOT JSON). gossamer reads the array +// through JNI and hands the app a `[*]const f32` + length — zero parsing. +// +// * Config and widget state cross as JSON strings (cold path, human-shaped). +// +// The app plugs in WITHOUT touching JNI: it registers a small set of pure C-ABI +// callbacks (see the `gossamer_android_register_*` exports), and gossamer owns +// every JNIEnv call, every global ref, and every array borrow/release. This is +// the same "app stays pure, gossamer owns the FFI" contract the webview IPC +// uses, specialised to the subclass-base shapes #71 introduces. +// +// Pure Zig, no Android headers: the registry, the ServiceHandle alloc/lookup/ +// free, and the JSON helper are HOST-RUNNABLE (see the `test` blocks). The +// `export fn Java_io_gossamer_*` entry points compile on the host as ordinary +// Zig but are never invoked there — there is no live JNIEnv off-device. + +const std = @import("std"); +const jni = @import("jni.zig"); + +/// Every `ServiceHandle` and the JSON it owns is allocated here so the JVM can +/// round-trip the pointer as a `long` across an arbitrary number of calls. +const c_alloc = std.heap.c_allocator; + +//============================================================================== +// Process-global app-callback registry +// +// The app registers PURE function pointers once (typically from its native +// `init` / `JNI_OnLoad`); gossamer stores them in these globals and invokes +// them from the JNI entry points below. None of these signatures mention a +// JNIEnv — that is the whole point. All are optional: a null slot means "no app +// handler", and gossamer applies a safe default (START_STICKY, "{}", do not +// restart, …). +//============================================================================== + +/// Service lifecycle callbacks (one set, process-wide — there is normally a +/// single foreground Service class per app). +/// create(handle, config_json) — service constructed +/// start(handle, action, flags, start_id) -> sticky — onStartCommand; the +/// return value is the Android START_* code (START_STICKY = 1 default) +/// destroy(handle) — service torn down +/// sensor(handle, type, values, len, ts_ns, accuracy) — one sensor sample; +/// `values` is borrowed for the call only (do not retain past return) +const ServiceCreateFn = *const fn (handle: u64, config_json: [*:0]const u8) callconv(.c) void; +const ServiceStartFn = *const fn (handle: u64, action: [*:0]const u8, flags: i32, start_id: i32) callconv(.c) i32; +const ServiceDestroyFn = *const fn (handle: u64) callconv(.c) void; +const ServiceSensorFn = *const fn (handle: u64, sensor_type: i32, values: [*]const f32, len: u32, timestamp_ns: i64, accuracy: i32) callconv(.c) void; + +/// Widget callbacks. +/// fetch_state(out_json_cap) -> json — return CURRENT widget state as a +/// handler-owned NUL-terminated JSON string. `out_json_cap` is an +/// advisory capacity hint (the RemoteViews text budget); the handler may +/// ignore it. The returned pointer is borrowed by gossamer for the +/// NewStringUTF copy only. +/// handle_action(action, widget_id) — a custom widget tap fired. +const WidgetFetchStateFn = *const fn (out_json_cap: usize) callconv(.c) [*:0]const u8; +const WidgetHandleActionFn = *const fn (action: [*:0]const u8, widget_id: i32) callconv(.c) void; + +/// Boot callback: should the named service class be restarted now? Return 1 to +/// restart, 0 to skip. Keyed by class name so one app can host several services. +const BootShouldRestartFn = *const fn (service_class: [*:0]const u8) callconv(.c) u8; + +/// Intent callback: the (services-variant) Activity was re-delivered an Intent. +/// Receives a small JSON envelope; gossamer extracts what it cheaply can. +const IntentOnIntentFn = *const fn (intent_json: [*:0]const u8) callconv(.c) void; + +var cb_service_create: ?ServiceCreateFn = null; +var cb_service_start: ?ServiceStartFn = null; +var cb_service_destroy: ?ServiceDestroyFn = null; +var cb_service_sensor: ?ServiceSensorFn = null; + +var cb_widget_fetch_state: ?WidgetFetchStateFn = null; +var cb_widget_handle_action: ?WidgetHandleActionFn = null; + +var cb_boot_should_restart: ?BootShouldRestartFn = null; + +var cb_intent_on_intent: ?IntentOnIntentFn = null; + +/// Advisory capacity hint passed to the widget `fetch_state` callback. The +/// RemoteViews text budget is small; 4 KiB is comfortably above any single +/// widget's JSON. Exposed as a constant so the value lives in exactly one place. +const WIDGET_STATE_CAP: usize = 4096; + +/// Register the foreground-Service lifecycle callbacks. Pass null for any the +/// app does not need; gossamer applies its default for the missing ones. Safe +/// to call again to re-point (idempotent, last writer wins). +export fn gossamer_android_register_service_callbacks( + create: ?ServiceCreateFn, + start: ?ServiceStartFn, + destroy: ?ServiceDestroyFn, + sensor: ?ServiceSensorFn, +) void { + cb_service_create = create; + cb_service_start = start; + cb_service_destroy = destroy; + cb_service_sensor = sensor; +} + +/// Register the widget callbacks. `fetch_state` returns a handler-owned +/// NUL-terminated JSON string (gossamer only reads it); `handle_action` reacts +/// to a custom widget tap. +export fn gossamer_android_register_widget_callbacks( + fetch_state: ?WidgetFetchStateFn, + handle_action: ?WidgetHandleActionFn, +) void { + cb_widget_fetch_state = fetch_state; + cb_widget_handle_action = handle_action; +} + +/// Register the boot callback deciding whether a service restarts on boot. +export fn gossamer_android_register_boot_callback( + should_restart: ?BootShouldRestartFn, +) void { + cb_boot_should_restart = should_restart; +} + +/// Register the Activity new-intent callback. +export fn gossamer_android_register_intent_callback( + on_intent: ?IntentOnIntentFn, +) void { + cb_intent_on_intent = on_intent; +} + +//============================================================================== +// ServiceHandle — per-service opaque native state (INDEPENDENT of GossamerHandle) +//============================================================================== + +/// Per-service native state. Allocated once at `nativeServiceCreate`, freed once +/// at `nativeServiceDestroy`; the JVM holds the only reference between the two as +/// a `long`. Deliberately NOT shared with the webview `GossamerHandle`: a Service +/// has its own lifetime and may run with no Activity in scope. +pub const ServiceHandle = struct { + /// Process JavaVM, cached so off-thread native workers (a sensor-processing + /// thread the app spins up) can attach and obtain their own env. JNIEnv is + /// strictly thread-local, so the env from `nativeServiceCreate` must never be + /// reused off the thread that created it — attach via this VM instead. + vm: ?jni.JavaVM = null, + /// Global ref to the Java `Service` object. Promoted from the local ref Java + /// passes at create (that local is invalid the instant the call returns), and + /// deleted at destroy. May be null if promotion failed. + service: jni.jobject = null, + /// The config JSON bytes handed in at create, owned (heap, NUL-terminated) + /// for the life of the handle so the app may read it after create returns. + config: [:0]u8, + /// Opaque app pointer, threaded through if the app wants to associate its own + /// per-service state. gossamer never dereferences it. + user_data: ?*anyopaque = null, +}; + +/// Recover a `*ServiceHandle` from the `long` the JVM round-trips. Returns null +/// for a 0 / negative handle (defensive: the Java side initialises the field to +/// 0 and only overwrites it on a successful create). +fn handleFromLong(handle: i64) ?*ServiceHandle { + if (handle <= 0) return null; + return @ptrFromInt(@as(usize, @intCast(handle))); +} + +/// Encode a `*ServiceHandle` as the `long` returned to the JVM. Mirrors the +/// `@intCast(@intFromPtr(...))` form `main.zig` uses for its channel handles. +fn handleToLong(h: *ServiceHandle) i64 { + return @intCast(@intFromPtr(h)); +} + +/// Allocate a `ServiceHandle` owning a copy of `config_json`. Host-testable: it +/// takes no JNIEnv and performs no JNI. Returns null on OOM. +fn allocServiceHandle(config_json: []const u8, user_data: ?*anyopaque) ?*ServiceHandle { + const h = c_alloc.create(ServiceHandle) catch return null; + const cfg = c_alloc.dupeZ(u8, config_json) catch { + c_alloc.destroy(h); + return null; + }; + h.* = .{ + .vm = null, + .service = null, + .config = cfg, + .user_data = user_data, + }; + return h; +} + +/// Free a `ServiceHandle` and the config it owns. Does NOT touch JNI (the global +/// ref is deleted by the caller while a valid env is in hand). Host-testable. +fn freeServiceHandle(h: *ServiceHandle) void { + c_alloc.free(h.config); + c_alloc.destroy(h); +} + +//============================================================================== +// Small JNI helpers (services-local) +//============================================================================== + +/// Read `android.content.Intent.getAction()` as an owned, NUL-terminated copy, +/// or null if `intent` is null / has no action / JNI fails. The caller owns the +/// returned slice and must free it with `c_alloc`. Kept self-contained so the +/// Service start path and the Activity intent path share one implementation. +fn intentActionOwned(env: jni.JNIEnv, intent: jni.jobject) ?[:0]u8 { + const obj = intent orelse return null; + const cls = jni.findClass(env, "android/content/Intent"); + if (cls == null) { + _ = jni.clearPendingException(env); + return null; + } + const mid = jni.getMethodID(env, cls, "getAction", "()Ljava/lang/String;"); + if (mid == null) { + _ = jni.clearPendingException(env); + return null; + } + const action_str = jni.callObjectMethod(env, obj, mid, &.{}); + _ = jni.clearPendingException(env); + const s = action_str orelse return null; + const chars = jni.getStringUTFChars(env, s) orelse return null; + defer jni.releaseStringUTFChars(env, s, chars); + return c_alloc.dupeZ(u8, std.mem.span(chars)) catch null; +} + +//============================================================================== +// JNI exports — GossamerForegroundService +// +// Names MUST match the Java declarations in +// io/gossamer/services/GossamerForegroundService.java exactly. These are STATIC +// native methods, so the second JNI argument is the defining `jclass`. +//============================================================================== + +/// `private static native long nativeServiceCreate(Service self, String configJson)` +/// +/// Promote `service` to a GLOBAL ref, copy the config bytes, allocate the +/// independent ServiceHandle, cache the JavaVM, invoke the app `create` callback, +/// and return the handle as a `long`. Returns 0 on allocation failure (the Java +/// side treats 0 as "no native handle" and simply never calls back in). +export fn Java_io_gossamer_services_GossamerForegroundService_nativeServiceCreate( + env: jni.JNIEnv, + _: jni.jclass, + service: jni.jobject, + config_json: jni.jstring, +) i64 { + // Read config (UTF chars) into an owned slice via the handle allocator. + var config_slice: []const u8 = "{}"; + var config_chars: ?[*:0]const u8 = null; + if (config_json != null) { + config_chars = jni.getStringUTFChars(env, config_json); + if (config_chars) |ch| config_slice = std.mem.span(ch); + } + defer if (config_chars) |ch| jni.releaseStringUTFChars(env, config_json, ch); + + const h = allocServiceHandle(config_slice, null) orelse return 0; + + // Cache the VM (for off-thread attach) and promote the Service to a global + // ref so it stays valid across the whole service lifetime. + h.vm = jni.getJavaVM(env); + h.service = jni.newGlobalRef(env, service); + + const handle_id: u64 = @intCast(@intFromPtr(h)); + if (cb_service_create) |cb| cb(handle_id, h.config.ptr); + _ = jni.clearPendingException(env); + return handleToLong(h); +} + +/// `private static native int nativeServiceStartCommand(long handle, Intent intent, int flags, int startId)` +/// +/// Recover the handle, extract the Intent action if present (else ""), dispatch +/// to the app `start` callback, and return its Android START_* code. Defaults to +/// START_STICKY (1) when there is no handle or no callback. +export fn Java_io_gossamer_services_GossamerForegroundService_nativeServiceStartCommand( + env: jni.JNIEnv, + _: jni.jclass, + handle: i64, + intent: jni.jobject, + flags: jni.jint, + start_id: jni.jint, +) i32 { + const START_STICKY: i32 = 1; + const h = handleFromLong(handle) orelse return START_STICKY; + const cb = cb_service_start orelse return START_STICKY; + + const action_owned = intentActionOwned(env, intent); + defer if (action_owned) |a| c_alloc.free(a); + const action_ptr: [*:0]const u8 = if (action_owned) |a| a.ptr else ""; + + const rc = cb(@intCast(@intFromPtr(h)), action_ptr, @intCast(flags), @intCast(start_id)); + _ = jni.clearPendingException(env); + return rc; +} + +/// `private static native void nativeServiceDestroy(long handle)` +/// +/// Dispatch the app `destroy` callback, delete the Service global ref (while a +/// valid env is in hand), then free the handle. Terminal: nothing may dispatch +/// against this handle afterwards (the JVM drops its `long`). +export fn Java_io_gossamer_services_GossamerForegroundService_nativeServiceDestroy( + env: jni.JNIEnv, + _: jni.jclass, + handle: i64, +) void { + const h = handleFromLong(handle) orelse return; + if (cb_service_destroy) |cb| cb(@intCast(@intFromPtr(h))); + if (h.service) |svc| jni.deleteGlobalRef(env, svc); + h.service = null; + _ = jni.clearPendingException(env); + freeServiceHandle(h); +} + +/// `private static native void nativeSensorEvent(long handle, int sensorType, float[] values, long timestampNs, int accuracy)` +/// +/// Borrow the `float[]` backing store, hand the app `sensor` callback a +/// `[*]const f32` + element count, then release the borrow with JNI_ABORT (the +/// native side only reads, so no copy-back). No allocation on this hot path. +export fn Java_io_gossamer_services_GossamerForegroundService_nativeSensorEvent( + env: jni.JNIEnv, + _: jni.jclass, + handle: i64, + sensor_type: jni.jint, + values: jni.jfloatArray, + timestamp_ns: i64, + accuracy: jni.jint, +) void { + const h = handleFromLong(handle) orelse return; + const cb = cb_service_sensor orelse return; + const arr = values orelse return; + + const len_signed = jni.getArrayLength(env, arr); + if (len_signed <= 0) return; + const elems = jni.getFloatArrayElements(env, arr) orelse return; + defer jni.releaseFloatArrayElements(env, arr, elems, jni.JNI_ABORT); + + const len: u32 = @intCast(len_signed); + cb(@intCast(@intFromPtr(h)), @intCast(sensor_type), elems, len, timestamp_ns, @intCast(accuracy)); + _ = jni.clearPendingException(env); +} + +//============================================================================== +// JNI exports — GossamerBootReceiver +//============================================================================== + +/// `private static native boolean nativeShouldRestart(Context context, String serviceClassName)` +/// +/// Read the service class name, ask the app `should_restart` callback, and +/// return its boolean. Defaults to false (0 / do not restart) when there is no +/// callback or the class name is unreadable — the conservative choice. +export fn Java_io_gossamer_services_GossamerBootReceiver_nativeShouldRestart( + env: jni.JNIEnv, + _: jni.jclass, + context: jni.jobject, + service_class_name: jni.jstring, +) jni.jboolean { + _ = context; + const cb = cb_boot_should_restart orelse return jni.JNI_FALSE; + const name_j = service_class_name orelse return jni.JNI_FALSE; + const name_chars = jni.getStringUTFChars(env, name_j) orelse return jni.JNI_FALSE; + defer jni.releaseStringUTFChars(env, name_j, name_chars); + + const restart = cb(name_chars); + _ = jni.clearPendingException(env); + return if (restart != 0) jni.JNI_TRUE else jni.JNI_FALSE; +} + +//============================================================================== +// JNI exports — GossamerAppWidgetProvider +//============================================================================== + +/// `private static native String nativeFetchWidgetState(Context context)` +/// +/// Call the app `fetch_state` callback and wrap its JSON in a Java String. With +/// no callback, returns "{}" so the subclass `renderWidget` always has valid +/// (empty) state to parse. +export fn Java_io_gossamer_services_GossamerAppWidgetProvider_nativeFetchWidgetState( + env: jni.JNIEnv, + _: jni.jclass, + context: jni.jobject, +) jni.jstring { + _ = context; + const json_ptr: [*:0]const u8 = if (cb_widget_fetch_state) |cb| + cb(WIDGET_STATE_CAP) + else + "{}"; + const result = jni.newStringUTF(env, json_ptr); + _ = jni.clearPendingException(env); + return result; +} + +/// `private static native void nativeHandleWidgetAction(Context context, String action, int widgetId)` +/// +/// Read the action string and dispatch the app `handle_action` callback. No-op +/// when there is no callback or the action is unreadable. +export fn Java_io_gossamer_services_GossamerAppWidgetProvider_nativeHandleWidgetAction( + env: jni.JNIEnv, + _: jni.jclass, + context: jni.jobject, + action: jni.jstring, + widget_id: jni.jint, +) void { + _ = context; + const cb = cb_widget_handle_action orelse return; + const action_j = action orelse return; + const action_chars = jni.getStringUTFChars(env, action_j) orelse return; + defer jni.releaseStringUTFChars(env, action_j, action_chars); + + cb(action_chars, @intCast(widget_id)); + _ = jni.clearPendingException(env); +} + +//============================================================================== +// JNI exports — GossamerActivity (services variant) +// +// The services source set's GossamerActivity adds nativeIntentReceived on top of +// the nativeInit/nativeDestroy already exported by webview_android.zig. (Both +// source sets are alternative builds; an app links exactly one, so this is the +// only definition of nativeIntentReceived and never collides.) +//============================================================================== + +/// `private static native void nativeIntentReceived(Intent intent)` +/// +/// Build a minimal JSON envelope describing the redelivered Intent — extracting +/// the action via JNI when present — and dispatch the app `on_intent` callback. +export fn Java_io_gossamer_GossamerActivity_nativeIntentReceived( + env: jni.JNIEnv, + _: jni.jclass, + intent: jni.jobject, +) void { + const cb = cb_intent_on_intent orelse return; + + const action_owned = intentActionOwned(env, intent); + defer if (action_owned) |a| c_alloc.free(a); + + const json = buildIntentJson(c_alloc, intent != null, if (action_owned) |a| a else null) catch return; + defer c_alloc.free(json); + + cb(json.ptr); + _ = jni.clearPendingException(env); +} + +/// Build the `nativeIntentReceived` envelope. Separated from the JNI export so +/// it is host-testable (it performs no JNI). Shape: +/// {"hasIntent":true,"action":"android.intent.action.VIEW"} +/// {"hasIntent":true} (intent present, no/unknown action) +/// {"hasIntent":false} (null intent) +fn buildIntentJson(alloc: std.mem.Allocator, has_intent: bool, action: ?[]const u8) ![:0]u8 { + const has = if (has_intent) "true" else "false"; + if (action) |a| { + return std.fmt.allocPrintSentinel( + alloc, + "{{\"hasIntent\":{s},\"action\":\"{s}\"}}", + .{ has, a }, + 0, + ); + } + return std.fmt.allocPrintSentinel(alloc, "{{\"hasIntent\":{s}}}", .{has}, 0); +} + +//============================================================================== +// Tests (host-runnable — registry, ServiceHandle lifecycle, JSON helper) +// +// These exercise the pure-Zig surface on a normal CI runner. The JNI exports +// above compile here but are never called (no live JNIEnv on the host). +//============================================================================== + +const testing = std.testing; + +/// Reset every callback global so tests do not leak registrations into one +/// another. Test-only; not part of the app-facing ABI. +fn resetCallbacksForTest() void { + cb_service_create = null; + cb_service_start = null; + cb_service_destroy = null; + cb_service_sensor = null; + cb_widget_fetch_state = null; + cb_widget_handle_action = null; + cb_boot_should_restart = null; + cb_intent_on_intent = null; +} + +// --- captured-call probes for the registered callbacks ------------------------ + +var probe_create_handle: u64 = 0; +var probe_start_calls: u32 = 0; +var probe_sensor_len: u32 = 0; +var probe_sensor_first: f32 = 0; + +fn testCreate(handle: u64, config_json: [*:0]const u8) callconv(.c) void { + probe_create_handle = handle; + _ = config_json; +} +fn testStartSticky(handle: u64, action: [*:0]const u8, flags: i32, start_id: i32) callconv(.c) i32 { + _ = handle; + _ = action; + _ = flags; + _ = start_id; + probe_start_calls += 1; + return 1; // START_STICKY +} +fn testSensor(handle: u64, sensor_type: i32, values: [*]const f32, len: u32, ts: i64, accuracy: i32) callconv(.c) void { + _ = handle; + _ = sensor_type; + _ = ts; + _ = accuracy; + probe_sensor_len = len; + if (len > 0) probe_sensor_first = values[0]; +} +fn testFetchState(out_json_cap: usize) callconv(.c) [*:0]const u8 { + _ = out_json_cap; + return "{\"value\":42}"; +} +fn testShouldRestart(service_class: [*:0]const u8) callconv(.c) u8 { + // Restart only the neurophone service; ignore everything else. + return if (std.mem.eql(u8, std.mem.span(service_class), "io.neurophone.Service")) 1 else 0; +} + +test "register_service_callbacks stores all four fn pointers" { + resetCallbacksForTest(); + gossamer_android_register_service_callbacks(&testCreate, &testStartSticky, null, &testSensor); + try testing.expect(cb_service_create != null); + try testing.expect(cb_service_start != null); + try testing.expect(cb_service_destroy == null); // passed null on purpose + try testing.expect(cb_service_sensor != null); +} + +test "register_widget/boot/intent callbacks store independently" { + resetCallbacksForTest(); + gossamer_android_register_widget_callbacks(&testFetchState, null); + gossamer_android_register_boot_callback(&testShouldRestart); + try testing.expect(cb_widget_fetch_state != null); + try testing.expect(cb_widget_handle_action == null); + try testing.expect(cb_boot_should_restart != null); + try testing.expect(cb_intent_on_intent == null); // never registered +} + +test "ServiceHandle alloc copies config, round-trips as a long, and frees" { + const h = allocServiceHandle("{\"sampleRate\":50}", null) orelse return error.OutOfMemory; + // Config is copied (owned), NUL-terminated, and matches the input. + try testing.expectEqualStrings("{\"sampleRate\":50}", std.mem.span(h.config.ptr)); + try testing.expectEqual(@as(u8, 0), h.config[h.config.len]); // sentinel present + + // long round-trip recovers the exact same pointer. + const as_long = handleToLong(h); + try testing.expect(as_long > 0); + const recovered = handleFromLong(as_long) orelse return error.TestUnexpectedResult; + try testing.expectEqual(h, recovered); + + freeServiceHandle(h); +} + +test "handleFromLong rejects non-positive handles" { + try testing.expect(handleFromLong(0) == null); + try testing.expect(handleFromLong(-1) == null); +} + +test "registered create callback observes the handle id" { + resetCallbacksForTest(); + gossamer_android_register_service_callbacks(&testCreate, null, null, null); + probe_create_handle = 0; + + const h = allocServiceHandle("{}", null) orelse return error.OutOfMemory; + defer freeServiceHandle(h); + const id: u64 = @intCast(@intFromPtr(h)); + // Simulate the dispatch the JNI create path performs (no JNIEnv needed). + if (cb_service_create) |cb| cb(id, h.config.ptr); + try testing.expectEqual(id, probe_create_handle); +} + +test "registered start callback returns the START_STICKY code" { + resetCallbacksForTest(); + gossamer_android_register_service_callbacks(null, &testStartSticky, null, null); + probe_start_calls = 0; + const cb = cb_service_start orelse return error.TestUnexpectedResult; + try testing.expectEqual(@as(i32, 1), cb(1, "android.intent.action.MAIN", 0, 7)); + try testing.expectEqual(@as(u32, 1), probe_start_calls); +} + +test "registered sensor callback receives the values pointer and length" { + resetCallbacksForTest(); + gossamer_android_register_service_callbacks(null, null, null, &testSensor); + probe_sensor_len = 0; + probe_sensor_first = 0; + const samples = [_]f32{ 9.81, 0.0, -0.3 }; + const cb = cb_service_sensor orelse return error.TestUnexpectedResult; + cb(1, 1, &samples, samples.len, 123456789, 3); + try testing.expectEqual(@as(u32, 3), probe_sensor_len); + try testing.expectEqual(@as(f32, 9.81), probe_sensor_first); +} + +test "boot callback routes by service class name" { + resetCallbacksForTest(); + gossamer_android_register_boot_callback(&testShouldRestart); + const cb = cb_boot_should_restart orelse return error.TestUnexpectedResult; + try testing.expectEqual(@as(u8, 1), cb("io.neurophone.Service")); + try testing.expectEqual(@as(u8, 0), cb("io.other.Service")); +} + +test "widget fetch_state callback yields its JSON" { + resetCallbacksForTest(); + gossamer_android_register_widget_callbacks(&testFetchState, null); + const cb = cb_widget_fetch_state orelse return error.TestUnexpectedResult; + try testing.expectEqualStrings("{\"value\":42}", std.mem.span(cb(WIDGET_STATE_CAP))); +} + +test "buildIntentJson covers null, action-less, and action cases" { + const a = std.testing.allocator; + + const no_intent = try buildIntentJson(a, false, null); + defer a.free(no_intent); + try testing.expectEqualStrings("{\"hasIntent\":false}", no_intent); + + const bare = try buildIntentJson(a, true, null); + defer a.free(bare); + try testing.expectEqualStrings("{\"hasIntent\":true}", bare); + + const with_action = try buildIntentJson(a, true, "android.intent.action.VIEW"); + defer a.free(with_action); + try testing.expectEqualStrings( + "{\"hasIntent\":true,\"action\":\"android.intent.action.VIEW\"}", + with_action, + ); +} diff --git a/src/interface/ffi/src/webview_android.zig b/src/interface/ffi/src/webview_android.zig index 63cb08e..188418b 100644 --- a/src/interface/ffi/src/webview_android.zig +++ b/src/interface/ffi/src/webview_android.zig @@ -3,70 +3,63 @@ // // Gossamer — Android WebView Platform Implementation // -// Provides platform-specific webview operations for Android using JNI -// to interface with android.webkit.WebView. +// Platform-specific webview operations for Android, driven over JNI against +// android.webkit.WebView. Compiled when targeting *-linux-android via the NDK +// (selected in main.zig by `builtin.abi == .android`). // -// This file is compiled when targeting aarch64-linux-android or -// x86_64-linux-android via the Android NDK. +// This file was rewritten to call REAL JNI. The previous version declared +// `extern fn jni_FindClass(...)` and friends — symbols defined nowhere, so the +// shell could never link. All JNI now goes through `jni.zig`, which models the +// per-thread JNIEnv function table directly (see that file for the rationale). // -// Dependencies: -// Android NDK (libjnigraphics, libandroid, liblog) -// Android SDK WebView component (API level 24+) +// Correctness fixes folded into the rewrite: +// * nativeInit now promotes the Activity/WebView to GLOBAL refs. The old code +// stored the raw local refs Java handed in; those are invalid the moment +// nativeInit returns, so every later call dereferenced freed handles. +// * The process JavaVM is cached so native threads (the run() loop, async IPC +// workers) can attach and obtain a valid env instead of reusing a stored +// env from another thread (JNIEnv is strictly thread-local). +// * IPC registration no longer constructs a second GossamerBridge and calls +// addJavascriptInterface a second time — the generated GossamerActivity +// already registers the bridge in onCreate. The native side only records +// the dispatch handle, removing the double-registration. // -// SPDX-License-Identifier: MPL-2.0 -// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// Dependencies at link time (NDK): liblog, libandroid. No webview .so is linked +// — android.webkit.WebView is reached entirely through JNI. const std = @import("std"); - -//============================================================================== -// JNI Type Definitions -//============================================================================== - -/// JNI environment pointer — wraps the function table used for all JNI calls. -/// On Android, each thread has its own JNIEnv obtained via AttachCurrentThread. -const JNIEnv = anyopaque; -/// Java object reference (opaque) -const jobject = ?*anyopaque; -/// Java class reference (opaque) -const jclass = ?*anyopaque; -/// Java method ID (opaque) -const jmethodID = ?*anyopaque; -/// Java string reference (opaque) -const jstring = ?*anyopaque; - -/// JNI function table pointers — accessed via double indirection on JNIEnv. -/// These are declared as extern C functions for Zig to call through. -extern fn jni_FindClass(env: *JNIEnv, name: [*:0]const u8) jclass; -extern fn jni_GetMethodID(env: *JNIEnv, cls: jclass, name: [*:0]const u8, sig: [*:0]const u8) jmethodID; -extern fn jni_NewStringUTF(env: *JNIEnv, str: [*:0]const u8) jstring; -extern fn jni_CallVoidMethod(env: *JNIEnv, obj: jobject, method: jmethodID, ...) void; -extern fn jni_NewObject(env: *JNIEnv, cls: jclass, method: jmethodID, ...) jobject; -extern fn jni_DeleteGlobalRef(env: *JNIEnv, ref: jobject) void; -extern fn jni_NewGlobalRef(env: *JNIEnv, ref: jobject) jobject; -extern fn jni_GetStringUTFChars(env: *JNIEnv, str: jstring, isCopy: ?*u8) ?[*:0]const u8; -extern fn jni_ReleaseStringUTFChars(env: *JNIEnv, str: jstring, chars: [*:0]const u8) void; +const jni = @import("jni.zig"); + +// Force the non-UI component host (Service/Receiver/Widget/Intent JNI exports +// from services_android.zig) into the Android image. It is reachable only +// through this platform module, so referencing it here is what makes its +// `export fn Java_io_gossamer_services_*` symbols part of libgossamer.so on +// Android — and only on Android. +comptime { + _ = @import("services_android.zig"); +} /// Platform-specific webview state for Android. /// Stored inside GossamerHandle.webview. pub const WebviewState = struct { - /// JNIEnv pointer — valid only on the thread that attached it - jni_env: ?*JNIEnv, - /// Global reference to the Android Activity (jobject) - activity: jobject, - /// Global reference to the Android WebView (jobject) - webview: jobject, - /// Cached class ref for android.webkit.WebView - webview_class: jclass, - /// Cached method IDs for WebView methods - mid_loadData: jmethodID, - mid_loadUrl: jmethodID, - mid_evaluateJavascript: jmethodID, - mid_addJavascriptInterface: jmethodID, - /// Cached method ID for Activity.setTitle - mid_setTitle: jmethodID, - /// Whether JNI has been initialised + /// JNIEnv captured at nativeInit — valid on the JVM UI thread. Off-thread + /// callers must obtain their own env via `currentEnv()` instead. + jni_env: ?jni.JNIEnv, + /// Global reference to the Android Activity (jobject). + activity: jni.jobject, + /// Global reference to the Android WebView (jobject). + webview: jni.jobject, + /// Cached class ref for android.webkit.WebView. + webview_class: jni.jclass, + /// Cached method IDs for WebView methods. + mid_loadData: jni.jmethodID, + mid_loadUrl: jni.jmethodID, + mid_evaluateJavascript: jni.jmethodID, + /// Cached method ID for Activity.setTitle. + mid_setTitle: jni.jmethodID, + /// Whether JNI has been initialised. jni_initialized: bool, - /// Shutdown signal — set by Java when Activity.onDestroy fires + /// Shutdown signal — set by Java when Activity.onDestroy fires. shutdown: bool, }; @@ -82,16 +75,18 @@ pub const PlatformError = error{ const GossamerHandle = @import("main.zig").GossamerHandle; const ipc = @import("ipc.zig"); -/// Create a new Android WebView. -/// -/// On Android, the Activity must already exist (created by the Java launcher). -/// This function attaches to the existing Activity's WebView via JNI and -/// caches all method IDs for fast subsequent calls. +/// Obtain a JNIEnv valid on the CURRENT thread: prefer the already-attached +/// env, otherwise attach via the cached JavaVM. Returns null if no VM is known. +fn currentEnv() ?jni.JNIEnv { + const vm = android_vm orelse return android_jni_env; + return jni.getEnv(vm, jni.JNI_VERSION_1_6) orelse jni.attachCurrentThread(vm); +} + +/// Create a new Android WebView binding. /// -/// NOTE: Android apps are launched by the Java runtime, not by native code. -/// The native library is loaded via System.loadLibrary("gossamer") from -/// a GossamerActivity Java class. The JNIEnv and Activity reference are -/// passed to JNI_OnLoad / native method calls. +/// On Android the Activity and WebView already exist (constructed by the +/// generated GossamerActivity). This attaches to them via JNI and caches the +/// method IDs used on the hot path. pub fn create( title: [*:0]const u8, width: u32, @@ -105,8 +100,8 @@ pub fn create( fullscreen: bool, visible: bool, ) PlatformError!WebviewState { - _ = title; // Set via Activity.setTitle after creation - _ = width; // Android fills the Activity + _ = title; // Applied via Activity.setTitle after creation. + _ = width; // Android fills the Activity. _ = height; _ = min_width; _ = min_height; @@ -117,44 +112,30 @@ pub fn create( _ = fullscreen; _ = visible; - // Check if JNI references have been provided by the Java launcher const env = android_jni_env orelse return PlatformError.JniInitFailed; const activity = android_activity orelse return PlatformError.JniInitFailed; const webview = android_webview orelse return PlatformError.WebviewCreateFailed; - // Cache WebView class and method IDs for performance - const wv_cls = jni_FindClass(env, "android/webkit/WebView"); + const wv_cls = jni.findClass(env, "android/webkit/WebView"); if (wv_cls == null) return PlatformError.WebviewCreateFailed; - const mid_loadData = jni_GetMethodID( + const mid_loadData = jni.getMethodID( env, wv_cls, - "loadData", - "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + "loadDataWithBaseURL", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", ); - const mid_loadUrl = jni_GetMethodID( - env, - wv_cls, - "loadUrl", - "(Ljava/lang/String;)V", - ); - const mid_evaluateJavascript = jni_GetMethodID( + const mid_loadUrl = jni.getMethodID(env, wv_cls, "loadUrl", "(Ljava/lang/String;)V"); + const mid_eval = jni.getMethodID( env, wv_cls, "evaluateJavascript", "(Ljava/lang/String;Landroid/webkit/ValueCallback;)V", ); - const mid_addJsi = jni_GetMethodID( - env, - wv_cls, - "addJavascriptInterface", - "(Ljava/lang/Object;Ljava/lang/String;)V", - ); - // Cache Activity.setTitle method ID - const act_cls = jni_FindClass(env, "android/app/Activity"); + const act_cls = jni.findClass(env, "android/app/Activity"); const mid_setTitle = if (act_cls != null) - jni_GetMethodID(env, act_cls, "setTitle", "(Ljava/lang/CharSequence;)V") + jni.getMethodID(env, act_cls, "setTitle", "(Ljava/lang/CharSequence;)V") else null; @@ -165,99 +146,91 @@ pub fn create( .webview_class = wv_cls, .mid_loadData = mid_loadData, .mid_loadUrl = mid_loadUrl, - .mid_evaluateJavascript = mid_evaluateJavascript, - .mid_addJavascriptInterface = mid_addJsi, + .mid_evaluateJavascript = mid_eval, .mid_setTitle = mid_setTitle, .jni_initialized = true, .shutdown = false, }; } -/// Load HTML content into the webview. -/// JNI call: webView.loadData(html, "text/html", "UTF-8") +/// Load HTML content. JNI: webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null). +/// loadDataWithBaseURL is used over loadData so that document.origin is stable +/// and the JS bridge / fetch behave consistently. pub fn loadHTML(state: *WebviewState, html: [*:0]const u8) PlatformError!void { const env = state.jni_env orelse return PlatformError.OperationFailed; const webview = state.webview orelse return PlatformError.OperationFailed; const mid = state.mid_loadData orelse return PlatformError.OperationFailed; - const j_html = jni_NewStringUTF(env, html); - const j_mime = jni_NewStringUTF(env, "text/html"); - const j_enc = jni_NewStringUTF(env, "UTF-8"); - - if (j_html == null or j_mime == null or j_enc == null) { - return PlatformError.OperationFailed; - } - - jni_CallVoidMethod(env, webview, mid, j_html, j_mime, j_enc); + const j_html = jni.newStringUTF(env, html); + const j_mime = jni.newStringUTF(env, "text/html"); + const j_enc = jni.newStringUTF(env, "UTF-8"); + if (j_html == null or j_mime == null or j_enc == null) return PlatformError.OperationFailed; + + jni.callVoidMethod(env, webview, mid, &.{ + jni.vObj(null), // baseUrl + jni.vObj(j_html), + jni.vObj(j_mime), + jni.vObj(j_enc), + jni.vObj(null), // historyUrl + }); + _ = jni.clearPendingException(env); } -/// Navigate to a URL. -/// JNI call: webView.loadUrl(url) +/// Navigate to a URL. JNI: webView.loadUrl(url). pub fn navigate(state: *WebviewState, url: [*:0]const u8) PlatformError!void { const env = state.jni_env orelse return PlatformError.OperationFailed; const webview = state.webview orelse return PlatformError.OperationFailed; const mid = state.mid_loadUrl orelse return PlatformError.OperationFailed; - const j_url = jni_NewStringUTF(env, url); + const j_url = jni.newStringUTF(env, url); if (j_url == null) return PlatformError.OperationFailed; - - jni_CallVoidMethod(env, webview, mid, j_url); + jni.callVoidMethod(env, webview, mid, &.{jni.vObj(j_url)}); + _ = jni.clearPendingException(env); } -/// Evaluate JavaScript in the webview context. -/// JNI call: webView.evaluateJavascript(js, null) -/// Requires API level 19+ (minSdk 24 guarantees this). +/// Evaluate JavaScript. JNI: webView.evaluateJavascript(js, null). +/// Uses an env valid on the current thread (the JS bridge callback runs on a +/// binder thread, not the UI thread). pub fn eval(state: *WebviewState, js: [*:0]const u8) PlatformError!void { - const env = state.jni_env orelse return PlatformError.OperationFailed; + const env = currentEnv() orelse return PlatformError.OperationFailed; const webview = state.webview orelse return PlatformError.OperationFailed; const mid = state.mid_evaluateJavascript orelse return PlatformError.OperationFailed; - const j_js = jni_NewStringUTF(env, js); + const j_js = jni.newStringUTF(env, js); if (j_js == null) return PlatformError.OperationFailed; - - // null ValueCallback — fire and forget (response comes via IPC bridge) - jni_CallVoidMethod(env, webview, mid, j_js, @as(jobject, null)); + jni.callVoidMethod(env, webview, mid, &.{ jni.vObj(j_js), jni.vObj(null) }); + _ = jni.clearPendingException(env); } -/// Set the window title (Activity title on Android). -/// JNI call: activity.setTitle(title) +/// Set the window title (Activity title on Android). JNI: activity.setTitle(title). pub fn setTitle(state: *WebviewState, title: [*:0]const u8) PlatformError!void { const env = state.jni_env orelse return PlatformError.OperationFailed; const activity = state.activity orelse return PlatformError.OperationFailed; const mid = state.mid_setTitle orelse return PlatformError.OperationFailed; - const j_title = jni_NewStringUTF(env, title); + const j_title = jni.newStringUTF(env, title); if (j_title == null) return PlatformError.OperationFailed; - - jni_CallVoidMethod(env, activity, mid, j_title); + jni.callVoidMethod(env, activity, mid, &.{jni.vObj(j_title)}); + _ = jni.clearPendingException(env); } -/// Resize the window (no-op on Android — WebView fills the Activity). -pub fn resize(_: *WebviewState, _: u32, _: u32) PlatformError!void { - // Android WebView fills the Activity — resize is not applicable -} +/// Resize is a no-op on Android — the WebView fills the Activity. +pub fn resize(_: *WebviewState, _: u32, _: u32) PlatformError!void {} -/// Window visibility/state controls are not supported on Android. +/// Window visibility/state controls are not applicable to a single-Activity +/// Android shell. pub fn show(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } - -/// Window visibility/state controls are not supported on Android. pub fn hide(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } - -/// Window visibility/state controls are not supported on Android. pub fn minimize(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } - -/// Window visibility/state controls are not supported on Android. pub fn maximize(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } - -/// Window visibility/state controls are not supported on Android. pub fn restore(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } @@ -271,7 +244,8 @@ pub fn getScreenSize(_: *WebviewState) [2]u32 { return .{ android_screen_width, android_screen_height }; } -/// Register a persistent user script. +/// User scripts are not yet wired on Android (would require WebViewClient +/// onPageFinished injection in the generated Java). pub fn addUserScript(_: *WebviewState, _: [*:0]const u8) PlatformError!void {} /// Z-order and move are not applicable on Android (single-activity model). @@ -279,34 +253,25 @@ pub fn raise(_: *WebviewState) PlatformError!void {} pub fn lower(_: *WebviewState) PlatformError!void {} pub fn moveTo(_: *WebviewState, _: i32, _: i32) PlatformError!void {} -/// Requesting close is not supported on Android from the native shell layer. +/// Requesting close from the native shell layer is not supported on Android. pub fn requestClose(_: *WebviewState) PlatformError!void { return PlatformError.OperationFailed; } -/// Run the event loop. -/// On Android, the Java runtime owns the event loop. This function blocks -/// by polling the shutdown flag, which is set when Activity.onDestroy fires. +/// Run the event loop. On Android the JVM owns the loop; this blocks the +/// native run-thread until Activity.onDestroy sets the shutdown flag. pub fn run(state: *WebviewState) void { - // Block until the Java side signals shutdown. - // On Android, the native library stays loaded as long as the Activity - // is alive. We poll with a short sleep to avoid burning CPU. while (!state.shutdown) { - std.time.sleep(50 * std.time.ns_per_ms); // 50ms poll + std.time.sleep(50 * std.time.ns_per_ms); } } -/// Destroy the webview and release JNI global references. +/// Destroy the webview binding and release JNI global references. pub fn destroy(state: *WebviewState) void { if (state.jni_initialized) { - if (state.jni_env) |env| { - // Delete global references to prevent Java-side memory leaks - if (state.activity) |activity| { - jni_DeleteGlobalRef(env, activity); - } - if (state.webview) |webview| { - jni_DeleteGlobalRef(env, webview); - } + if (currentEnv()) |env| { + if (state.activity) |activity| jni.deleteGlobalRef(env, activity); + if (state.webview) |webview| jni.deleteGlobalRef(env, webview); } state.jni_env = null; state.activity = null; @@ -316,69 +281,37 @@ pub fn destroy(state: *WebviewState) void { } } -/// Register IPC handler for Android WebView. -/// -/// Uses WebView.addJavascriptInterface() to expose a "GossamerBridge" -/// object to JavaScript. When JS calls GossamerBridge.postMessage(msg), -/// the registered Java callback dispatches to handle.bindings. +/// Register the IPC dispatch handle for the Android WebView. /// -/// The Java-side GossamerBridge class must exist in the APK and implement -/// @JavascriptInterface void postMessage(String msg). This is defined in -/// io.gossamer.GossamerBridge.java (shipped with the Gossamer Android SDK). -pub fn registerIPCHandler(state: *WebviewState, handle: *GossamerHandle) PlatformError!void { - const env = state.jni_env orelse return PlatformError.OperationFailed; - const webview = state.webview orelse return PlatformError.OperationFailed; - const mid = state.mid_addJavascriptInterface orelse return PlatformError.OperationFailed; - - // Store handle reference for the Java callback to use +/// The generated GossamerActivity already registered the GossamerBridge JS +/// interface in onCreate, so this only records the native handle that +/// GossamerBridge.nativePostMessage dispatches against. (The old code +/// constructed a second bridge and re-registered it — a double registration +/// that this removes.) +pub fn registerIPCHandler(_: *WebviewState, handle: *GossamerHandle) PlatformError!void { ipc_handle = handle; - - // Find the GossamerBridge Java class (must be in the APK) - const bridge_cls = jni_FindClass(env, "io/gossamer/GossamerBridge"); - if (bridge_cls == null) return PlatformError.OperationFailed; - - // Locate constructor: GossamerBridge(long nativePtr) - const bridge_init = jni_GetMethodID(env, bridge_cls, "", "(J)V"); - if (bridge_init == null) return PlatformError.OperationFailed; - - // Construct a new GossamerBridge instance via NewObject, passing the - // native handle pointer as the long constructor argument. - // jni_NewObject is the correct JNI call for constructing new objects; - // jni_CallObjectMethod would call an instance method, not a constructor. - const native_ptr: i64 = @intCast(@intFromPtr(handle)); - const bridge = jni_NewObject(env, bridge_cls, bridge_init, native_ptr); - if (bridge == null) return PlatformError.OperationFailed; - - // webView.addJavascriptInterface(bridge, "GossamerBridge") - const j_name = jni_NewStringUTF(env, "GossamerBridge"); - if (j_name == null) return PlatformError.OperationFailed; - - jni_CallVoidMethod(env, webview, mid, bridge, j_name); } //============================================================================== // IPC Message Handling //============================================================================== -/// Thread-local handle reference for the Java callback. +/// Dispatch handle for the Java bridge callback. var ipc_handle: ?*GossamerHandle = null; -/// Called from Java GossamerBridge.postMessage(@JavascriptInterface). -/// The Java side receives the JSON string from the JS bridge and forwards -/// it here via JNI. We parse the message, dispatch to the bound callback, -/// and send the response back via evaluateJavascript. +/// Called from Java GossamerBridge.postMessage (@JavascriptInterface). +/// Parses the JSON IPC message, dispatches to the bound callback, and sends the +/// response back via evaluateJavascript. export fn Java_io_gossamer_GossamerBridge_nativePostMessage( - env: ?*JNIEnv, - _: jobject, // this (GossamerBridge instance) - message: jstring, + env: jni.JNIEnv, + _: jni.jobject, // this (GossamerBridge instance) + message: jni.jstring, ) void { const handle = ipc_handle orelse return; - const jni = env orelse return; const msg = message orelse return; - // Extract UTF-8 string from Java String - const msg_chars = jni_GetStringUTFChars(jni, msg, null) orelse return; - defer jni_ReleaseStringUTFChars(jni, msg, msg_chars); + const msg_chars = jni.getStringUTFChars(env, msg) orelse return; + defer jni.releaseStringUTFChars(env, msg, msg_chars); const msg_slice = std.mem.span(msg_chars); // Parse the IPC envelope (id, name, payload) with a real JSON parser. @@ -391,8 +324,7 @@ export fn Java_io_gossamer_GossamerBridge_nativePostMessage( if (name.len == 0) return; const payload = parsed.value.payload; - // Look up the bound callback - const callback = handle.bindings.get(name) orelse { + const entry = handle.bindings.get(name) orelse { sendIPCError(handle, id, "No handler bound for command"); return; }; @@ -400,7 +332,7 @@ export fn Java_io_gossamer_GossamerBridge_nativePostMessage( // Invoke the callback with the payload const payload_z = allocator.dupeZ(u8, payload) catch return; defer allocator.free(payload_z); - const response_ptr = callback(payload_z); + const response_ptr = entry.callback(payload_z, entry.user_data); const response = std.mem.span(response_ptr); sendIPCResponse(handle, id, response); } @@ -413,10 +345,11 @@ fn sendIPCResponse(handle: *GossamerHandle, id: []const u8, response: []const u8 const allocator = std.heap.c_allocator; const escaped = escapeForJS(allocator, response) catch return; defer allocator.free(escaped); - const js = std.fmt.allocPrintZ( + const js = std.fmt.allocPrintSentinel( allocator, "if (window.__gossamer_callbacks[\"{s}\"]) {{ window.__gossamer_callbacks[\"{s}\"].resolve(JSON.parse(\"{s}\")); delete window.__gossamer_callbacks[\"{s}\"]; }}", .{ id, id, escaped, id }, + 0, ) catch return; defer allocator.free(js); eval(&handle.webview, js) catch {}; @@ -424,10 +357,11 @@ fn sendIPCResponse(handle: *GossamerHandle, id: []const u8, response: []const u8 fn sendIPCError(handle: *GossamerHandle, id: []const u8, msg_text: []const u8) void { const allocator = std.heap.c_allocator; - const js = std.fmt.allocPrintZ( + const js = std.fmt.allocPrintSentinel( allocator, "if (window.__gossamer_callbacks[\"{s}\"]) {{ window.__gossamer_callbacks[\"{s}\"].reject(new Error(\"{s}\")); delete window.__gossamer_callbacks[\"{s}\"]; }}", .{ id, id, msg_text, id }, + 0, ) catch return; defer allocator.free(js); eval(&handle.webview, js) catch {}; @@ -449,55 +383,46 @@ fn escapeForJS(allocator: std.mem.Allocator, input: []const u8) ![]u8 { return result.toOwnedSlice(allocator); } -//============================================================================== -// JNI Entry Points -//============================================================================== - -/// Thread-local JNI references set by the Java launcher. -/// These are populated when the GossamerActivity calls native methods. -var android_jni_env: ?*JNIEnv = null; -var android_activity: jobject = null; -var android_webview: jobject = null; -/// Screen pixel dimensions cached from nativeInit — populated by Java before use. +/// Cached references set by the Java launcher at nativeInit. +var android_jni_env: ?jni.JNIEnv = null; +var android_vm: ?jni.JavaVM = null; +var android_activity: jni.jobject = null; +var android_webview: jni.jobject = null; +/// Screen pixel dimensions cached from nativeInit (populated by Java). var android_screen_width: u32 = 1080; var android_screen_height: u32 = 1920; -/// Called by Java via JNI to provide the Activity, WebView, and screen dimensions. +/// Called by Java to hand over the Activity, WebView, and screen dimensions. /// -/// Java signature: -/// native void nativeInit(Activity activity, WebView webview, -/// int screenWidth, int screenHeight) +/// Java signature (generated GossamerActivity): +/// native void nativeInit(Activity activity, WebView webview, int w, int h) /// -/// The Java side must create global references before calling this: -/// nativeInit(NewGlobalRef(activity), NewGlobalRef(webview), -/// displayMetrics.widthPixels, displayMetrics.heightPixels) +/// The local refs Java passes are valid only for this call, so we promote them +/// to GLOBAL refs before storing. We also cache the process JavaVM so other +/// threads can attach. export fn Java_io_gossamer_GossamerActivity_nativeInit( - env: ?*JNIEnv, - _: jobject, // this (GossamerActivity) - activity: jobject, - webview: jobject, - screen_width: i32, - screen_height: i32, + env: jni.JNIEnv, + _: jni.jobject, // this (GossamerActivity) + activity: jni.jobject, + webview: jni.jobject, + screen_width: jni.jint, + screen_height: jni.jint, ) void { android_jni_env = env; - android_activity = activity; - android_webview = webview; - // Cache screen dimensions so getScreenSize() can return meaningful values - // without additional JNI calls on every query. + android_vm = jni.getJavaVM(env); + android_activity = jni.newGlobalRef(env, activity); + android_webview = jni.newGlobalRef(env, webview); if (screen_width > 0) android_screen_width = @intCast(screen_width); if (screen_height > 0) android_screen_height = @intCast(screen_height); } -/// Called by Java when the Activity is destroyed. -/// Signals the native run loop to exit and clears references. +/// Called by Java when the Activity is destroyed. Signals the native run loop +/// to exit and clears references. export fn Java_io_gossamer_GossamerActivity_nativeDestroy( - _: ?*JNIEnv, - _: jobject, + _: jni.JNIEnv, + _: jni.jobject, ) void { - // Signal shutdown to the run() poll loop - if (ipc_handle) |handle| { - handle.webview.shutdown = true; - } + if (ipc_handle) |handle| handle.webview.shutdown = true; android_jni_env = null; android_activity = null; android_webview = null; diff --git a/tests/e2e.sh b/tests/e2e.sh index 264a49f..4552119 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -167,16 +167,35 @@ else pass "No @panic in FFI production code" fi -# No believe_me in Idris2 ABI — exclude doc-comment lines ("||| ...") and -# line comments ("-- ...") so the test only flags real code uses. +# No believe_me/assert_total in Idris2 ABI, with two principled exclusions: +# 1. doc-comment ("||| ...") and line-comment ("-- ...") lines — prose, not code; +# 2. definitions carrying the `%unsafe` pragma — the canonical Idris2 marker for +# an audited escape hatch. The estate's proof convention permits exactly these +# as documented "class-J axioms" (principled assumptions over backend +# primitives), e.g. PanelIsolation.stringNotEqCommut (standards#131). The +# pragma attaches to the immediately-following declaration, so `unsafe` is set +# on `%unsafe` and cleared at the next blank line that separates top-level +# defs. An UNannotated believe_me/assert_total still fails the gate. ABI_DIR="src/interface/abi" if [ -d "$ABI_DIR" ]; then - DANGEROUS=$(grep -rn 'believe_me\|assert_total' "$ABI_DIR/" 2>/dev/null \ - | grep -vE '^[^:]+:[0-9]+:[[:space:]]*(\|\|\||--)' || true) + DANGEROUS="" + while IFS= read -r f; do + offending=$(awk ' + /^[[:space:]]*%unsafe/ { unsafe = 1 } + /^[[:space:]]*$/ { unsafe = 0 } + { + if ($0 ~ /^[[:space:]]*(\|\|\||--)/) next + if ($0 ~ /believe_me|assert_total/ && unsafe == 0) + printf "%s:%d:%s\n", FILENAME, NR, $0 + } + ' "$f") + [ -n "$offending" ] && DANGEROUS="${DANGEROUS}${offending}"$'\n' + done < <(find "$ABI_DIR" -name '*.idr' 2>/dev/null) + DANGEROUS="$(printf '%s' "$DANGEROUS" | sed '/^$/d')" if [ -n "$DANGEROUS" ]; then - fail_test "Dangerous Idris2 patterns in ABI" + fail_test "Dangerous Idris2 patterns in ABI ($(printf '%s\n' "$DANGEROUS" | wc -l) unsanctioned)" else - pass "No dangerous Idris2 patterns in ABI" + pass "No dangerous Idris2 patterns in ABI (%unsafe class-J axioms exempt)" fi else skip_test "ABI safety" "src/interface/abi/ not found"