Skip to content

gossamer-android-services: four shim base classes for platform-required JVM bytecode boundaries #71

@hyperpolymath

Description

@hyperpolymath

Why

Android instantiates the following classes by name from JVM bytecode at platform boundaries — Rust/Zig cannot satisfy these contracts directly:

Boundary Android base class Why no Rust/Zig
Long-running background work android.app.Service Class loader demands a JVM-callable onCreate / onStartCommand / onDestroy.
Boot start / system broadcasts android.content.BroadcastReceiver Same — class loader instantiates by <receiver android:name=".X"> in manifest.
Home-screen widgets android.appwidget.AppWidgetProvider (extends BroadcastReceiver) RemoteViews setters must be assembled in-process from Java.
Activity-with-bridge android.app.Activity Already handled by existing GossamerActivity.

The estate-wide language policy bans Kotlin and (modulo a tight android-shim carve-out) Java. The cleanest compliance is minimal Java shim base classes in this repo that delegate to native (Zig/Rust) via JNI, so consumers extend them with ~5-20 lines of subclass code per surface.

This issue tracks the design + landing of a gossamer-android-services companion module here in hyperpolymath/gossamer.

Motivating consumer

hyperpolymath/neurophone — RFC hyperpolymath/neurophone#97 is migrating from Kotlin/Android to Gossamer. Currently 7 Kotlin files + 3 *.gradle.kts (~926 LoC) implement: foreground service with sensor capture, boot-restart receiver, three home-screen widget classes, intent handling. The migration is blocked on this companion landing per owner directive 2026-06-02 (option Q6: "contribute upstream NOW").

Future consumers (any estate Android app, idaptik-ums Android port, etc.) inherit the same base classes for free.

Proposed module layout

gossamer/android/
├── src/main/java/io/gossamer/
│   ├── GossamerActivity.java                  [existing, extend with onIntentReceived hook]
│   ├── GossamerBridge.java                    [existing]
│   └── services/
│       ├── GossamerForegroundService.java     [NEW]
│       ├── GossamerBootReceiver.java          [NEW]
│       └── GossamerAppWidgetProvider.java     [NEW]
├── README.adoc                                [update]
└── tests/
    └── services/                              [NEW — subclassing surface tests]

gossamer/ffi/
├── webview_android.zig                        [existing]
└── services_android.zig                       [NEW — service/receiver/widget JNI exports]

API sketch — four base classes

1. GossamerForegroundService extends android.app.Service

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 h, Intent intent, int flags, int startId);
    private static native void  nativeServiceDestroy(long h);
    private static native void  nativeSensorEvent(long h, int sensorType, float[] values,
                                                   long timestampNs, int accuracy);

    private long nativeHandle;
    private SensorManager sensorManager;
    private PowerManager.WakeLock wakeLock;

    /** Foreground notification (REQUIRED for Service.startForeground). */
    protected abstract Notification createForegroundNotification();
    protected abstract String       getChannelId();
    protected abstract int          getNotificationId();

    /** JSON config blob passed to native on create. Default: "{}". */
    protected String getNativeConfig() { return "{}"; }

    /** Android sensor types to subscribe to. Default: none. */
    protected int[]  getSubscribedSensors()  { return new int[0]; }
    protected int    getSensorSamplingRate() { return SensorManager.SENSOR_DELAY_GAME; }

    /** Wake lock tag + acquisition policy. Default: no wake lock. */
    protected String getWakeLockTag() { return null; }

    @Override public final void onCreate() { /* channel + nativeServiceCreate + wakeLock + sensors */ }
    @Override public final int  onStartCommand(...) { /* startForeground + nativeServiceStartCommand */ }
    @Override public final void onDestroy() { /* reverse */ }
    @Override public final IBinder onBind(Intent i) { return null; }

    // SensorEventListener default impl delegates to nativeSensorEvent;
    // subclasses can override if they need pre-filtering before native sees the event.
    @Override public void onSensorChanged(SensorEvent e) {
        nativeSensorEvent(nativeHandle, e.sensor.getType(), e.values, e.timestamp, e.accuracy);
    }
    @Override public void onAccuracyChanged(Sensor s, int a) {}
}

Neurophone subclass — ~12 lines:

public class NeurophoneRuntimeService extends GossamerForegroundService {
    @Override protected Notification createForegroundNotification() { /* Notification.Builder */ }
    @Override protected String getChannelId() { return "neurophone_runtime"; }
    @Override protected int    getNotificationId() { return 0x4E50; }
    @Override protected String getNativeConfig() { return /* JSON config */; }
    @Override protected int[]  getSubscribedSensors() {
        return new int[] { Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_GYROSCOPE,
                           Sensor.TYPE_MAGNETIC_FIELD, Sensor.TYPE_LIGHT, Sensor.TYPE_PROXIMITY };
    }
    @Override protected String getWakeLockTag() { return "neurophone:service"; }
}

2. GossamerBootReceiver extends BroadcastReceiver

public abstract class GossamerBootReceiver extends BroadcastReceiver {
    static { System.loadLibrary("gossamer"); }

    private static native boolean nativeShouldRestart(Context context, String serviceClassName);

    /** Service class to restart on boot. */
    protected abstract Class<? extends Service> getServiceClass();

    @Override
    public final void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (!Intent.ACTION_BOOT_COMPLETED.equals(action)
            && !Intent.ACTION_LOCKED_BOOT_COMPLETED.equals(action)) return;

        if (nativeShouldRestart(context, getServiceClass().getName())) {
            context.startForegroundService(new Intent(context, getServiceClass()));
        }
    }
}

Neurophone subclass — 3 lines:

public class NeurophoneBootReceiver extends GossamerBootReceiver {
    @Override protected Class<? extends Service> getServiceClass() { return NeurophoneRuntimeService.class; }
}

3. GossamerAppWidgetProvider extends AppWidgetProvider

RemoteViews setters must run in Java (cannot be assembled cleanly from JNI), so the abstraction is: Java fetches widget state from native, then renders RemoteViews based on it.

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 for the widget. */
    @LayoutRes protected abstract int getWidgetLayout();

    /** Prefix for custom widget intent actions (e.g. "ai.neurophone.widget."). */
    protected abstract String getActionPrefix();

    /** Render the widget given the native state (typically JSON). */
    protected abstract void renderWidget(RemoteViews views, String nativeStateJson, int widgetId);

    @Override
    public final void onUpdate(Context context, AppWidgetManager mgr, int[] ids) {
        String state = nativeFetchWidgetState(context);
        for (int id : ids) {
            RemoteViews v = new RemoteViews(context.getPackageName(), getWidgetLayout());
            renderWidget(v, state, id);
            mgr.updateAppWidget(id, v);
        }
    }

    @Override
    public final void onReceive(Context context, Intent intent) {
        super.onReceive(context, intent);
        String action = intent.getAction();
        if (action != null && action.startsWith(getActionPrefix())) {
            int id = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, -1);
            nativeHandleWidgetAction(context, action, id);
            triggerRedraw(context);
        }
    }

    private void triggerRedraw(Context context) {
        AppWidgetManager mgr = AppWidgetManager.getInstance(context);
        int[] ids = mgr.getAppWidgetIds(new ComponentName(context, getClass()));
        if (ids.length > 0) onUpdate(context, mgr, ids);
    }
}

Neurophone subclass — ~20 lines:

public class NeurophoneAppWidget extends GossamerAppWidgetProvider {
    @Override protected int    getWidgetLayout()  { return R.layout.widget_neurophone; }
    @Override protected String getActionPrefix() { return "ai.neurophone.widget."; }
    @Override protected void renderWidget(RemoteViews v, String stateJson, int id) {
        // Parse stateJson { running, salience, description } and set RemoteViews fields.
        // Wire button PendingIntents to ACTION_TOGGLE / ACTION_REFRESH / ACTION_QUERY.
    }
}

4. Existing GossamerActivity — minor extension

Add a single hook:

/** Override to handle incoming intents (MAIN, SEND, VIEW, ASSIST, deep-links). */
protected void onIntentReceived(Intent intent) {
    nativeIntentReceived(intent);
}

private static native void nativeIntentReceived(Intent intent);

@Override
protected void onNewIntent(Intent intent) {
    super.onNewIntent(intent);
    setIntent(intent);
    onIntentReceived(intent);
}

Backwards-compatible — existing subclasses unaffected unless they override the new hook.

Native (Zig) surface

gossamer/ffi/services_android.zig mirrors the existing webview_android.zig pattern (JNI types, function table indirection, opaque handle, error type).

Approximate exports:

// Service lifecycle
export fn Java_io_gossamer_services_GossamerForegroundService_nativeServiceCreate(
    env: *JNIEnv, cls: jclass, service: jobject, config_json: jstring) jlong;
export fn Java_io_gossamer_services_GossamerForegroundService_nativeServiceStartCommand(
    env: *JNIEnv, cls: jclass, handle: jlong, intent: jobject, flags: jint, start_id: jint) jint;
export fn Java_io_gossamer_services_GossamerForegroundService_nativeServiceDestroy(
    env: *JNIEnv, cls: jclass, handle: jlong) void;
export fn Java_io_gossamer_services_GossamerForegroundService_nativeSensorEvent(
    env: *JNIEnv, cls: jclass, handle: jlong, sensor_type: jint, values: jfloatArray,
    timestamp_ns: jlong, accuracy: jint) void;

// Boot receiver
export fn Java_io_gossamer_services_GossamerBootReceiver_nativeShouldRestart(
    env: *JNIEnv, cls: jclass, context: jobject, service_class_name: jstring) jboolean;

// Widget
export fn Java_io_gossamer_services_GossamerAppWidgetProvider_nativeFetchWidgetState(
    env: *JNIEnv, cls: jclass, context: jobject) jstring;
export fn Java_io_gossamer_services_GossamerAppWidgetProvider_nativeHandleWidgetAction(
    env: *JNIEnv, cls: jclass, context: jobject, action: jstring, widget_id: jint) void;

// Activity intent
export fn Java_io_gossamer_GossamerActivity_nativeIntentReceived(
    env: *JNIEnv, cls: jclass, intent: jobject) void;

Open design question: handle ownership. The current webview_android.zig uses GossamerHandle from main.zig. For services we likely want a separate ServiceHandle type with its own JNI state, so a service running in the background doesn't share state with the foreground Activity's webview. Recommend independent handles + an explicit IPC primitive for the rare case where the service needs to talk to the foreground UI (e.g. neurophone's "publish neural-context to widget" tick).

Default implementations / payload conventions

Consumers should NOT have to invent payload formats. The companion provides conventions:

  • getNativeConfig() returns JSON. Conventional schema documented in services/README.adoc. Default empty {}.
  • nativeFetchWidgetState returns JSON. Top-level object with whatever the widget needs; rendering is the subclass's job (because RemoteViews keys vary by layout).
  • getActionPrefix() is mandatory — namespaces widget broadcast actions to avoid collisions in multi-widget apps.

What this PR does NOT do

  • Does NOT replace Gossamer's existing webview/Activity layer. The four shim classes are additive.
  • Does NOT introduce any consumer-specific code (no neurophone_* anywhere). Consumer-specific logic lives in the consumer repo's subclass.
  • Does NOT change the GossamerBridge IPC mechanism. Service-side IPC (if any) is a separate design question, not part of this companion.

Test plan

  • Each base class has an emulator-level test that builds the class on its own (without a consumer), verifies it can be instantiated as an Android Service/BroadcastReceiver/AppWidgetProvider, and that the native* JNI exports exist (avoid UnsatisfiedLinkError).
  • At least one synthetic consumer in tests/services/ exercises each subclass override path: getChannelId, getServiceClass, getWidgetLayout, renderWidget. A tiny tests/services/no-op-app/ Android app subclasses each.
  • Integration: neurophone's NeurophoneRuntimeService subclass compiles + boots a foreground service in the emulator. Validates the API shape against the real consumer.
  • Negative: a subclass that fails to call super.onCreate() produces a clear error (use final on lifecycle methods to make this impossible at compile time).

Sequencing — what unblocks what

This companion is blocking for hyperpolymath/neurophone#97 sub-PRs #3-#9 per Q6 of the RFC. Recommended sequence:

  1. This issue discussion + design approval.
  2. Companion PR lands here in Gossamer (this companion module).
  3. neurophone sub-PR fix(rhodibot): automated RSR compliance fixes #3 — Gossamer scaffolding + NeurophoneMainActivity extends GossamerActivity.
  4. neurophone sub-PRs fix(rhodibot): automated RSR compliance fixes #5/chore: remove committed zig build outputs #6/Feat/window controls api recovery #7 — Service/BootReceiver/Widget subclasses.
  5. neurophone sub-PR feat: recover window controls API #8 — AffineScript UI.
  6. neurophone sub-PR Add unit tests, benchmarks, and ABI stability proofs for Gossamer #9 — delete legacy Kotlin tree.

Roadmap alignment

This implements several of the unchecked Phase 3 Android items in ROADMAP.adoc:122-127:

  • [ ] JNI FindClass / GetMethodID / CallVoidMethod for WebView operations — required for the widget RemoteViews fetch path too.
  • [ ] addJavascriptInterface for IPC bridge — already done by GossamerBridge; the companion uses the same pattern for the service IPC.
  • [ ] Android NDK build integration — the companion adds services_android.zig to the build.
  • [ ] Gradle project scaffolding for Java launcher — the companion clarifies the Gradle layout for service-bearing apps.

Related

  • hyperpolymath/neurophone#97 — Kotlin → Gossamer migration RFC (the consumer driving this).
  • hyperpolymath/neurophone/docs/migrations/RFC-ANDROID-KOTLIN-TO-RUST.adoc — full migration plan.
  • hyperpolymath/neurophone/docs/migrations/JNI-SURFACE-AUDIT.adoc — JNI surface audit for the Rust-side core (separate from this Java shim layer; the two meet at the JNI boundary).
  • hyperpolymath/.git-private-farm#66 — propagation primitive (unrelated; same session).

Owner sign-off ask

  1. API surface OK? Specifically: the four base classes, the final on lifecycle methods (forces subclasses to override hooks rather than rewriting lifecycle), the JSON conventions for config/state.
  2. Module layout OK? io.gossamer.services.* subpackage; services_android.zig sibling to webview_android.zig.
  3. Handle ownership — independent ServiceHandle vs sharing GossamerHandle? Recommend independent.
  4. Subscribed-sensors API — currently int[] getSubscribedSensors(). Alternative: a more typed builder. Keeping it primitive matches the rest of the JNI surface.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions