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}.
+ *
+ *
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"