E3: the Kotlin/Java shell over a JNI bridge into Rust (RUST.md)

Two Java classes (MainActivity, NotificationService) hand their lifecycle
to a new android-shell crate built on client-core; client-core gains
notifications.rs (the /notifications SSE parse and attention_line, ported
from Notifications.kt). Packaged as a new app/shellApp Gradle module
rather than a rewrite of app/androidApp in place, so that module's working
Compose UI is untouched.

Both pass conditions held on the emulator: a notification arrived in
Android's drawer with the app closed, and a shared text share landed as a
real message in a sandbox session's transcript. Found and fixed three
real bugs along the way (a silently-wrong JNI signature from a generic
JObject parameter, a class-by-name lookup failing on this crate's own
background thread for lack of an app ClassLoader, and onStartCommand
opening two /notifications connections per enrollment -- the last a
latent bug in Notifications.kt itself). Full account, exact commands and
what was deliberately cut are in RUST.md's E3 box.

Co-Authored-By: Claude Sonnet <noreply@anthropic.com>
This commit is contained in:
irisandClaude Sonnet committed 2026-09-05 06:47:32 -04:00
1 parent 8adda94a7a
commit c9b273ff16
16 files changed
+2996 -6

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
//! The JNI bridge behind E3's two Java stub classes. See `Cargo.toml`'s
//! package comment for what this crate is and RUST.md's E3 entry for the
//! design decisions.
//!
//! Each native method is declared with `jni`'s [`native_method!`] macro
//! rather than a hand-written `#[no_mangle] extern "system" fn Java_...`:
//! the macro derives the mangled export name and the JNI signature from the
//! Rust function itself, so the two cannot drift apart the way a
//! hand-typed name string and a hand-typed `"(Landroid/...;)V"` signature
//! routinely do. `error_policy = LogErrorAndDefault` matches
//! `Notifications.kt`'s own posture: a failure here (a lost connection, a
//! JNI call that threw) is reported to logcat, not thrown back into Java
//! as an exception that would crash the app over something recoverable.
//!
//! Each `const _: NativeMethod = native_method! { ... };` binding is
//! otherwise unused by name -- `_` is the idiomatic way to keep a
//! side-effecting const (here, generating the `#[export_name]`d function
//! the JVM resolves by the JNI naming convention) without a `dead_code`
//! warning for a binding nothing reads.
mod jcall;
mod notify;
mod settings;
mod share;
use jni::errors::LogErrorAndDefault;
use jni::objects::{JClass, JObject};
use jni::sys::jint;
use jni::{Env, NativeMethod, native_method};
/// Installs the `log` backend that routes to logcat, once per process.
/// Without it, `LogErrorAndDefault` (every native method below) and any
/// `log::error!` inside `jni` itself (e.g. `JString`'s `Display` fallback)
/// call into the `log` facade's default no-op logger, and a real failure
/// vanishes with nothing on logcat to say so -- silently *more* wrong than
/// crashing, since nothing on screen or in the log says a notification was
/// dropped. Called from every entry point below rather than a Java-side
/// `Application.onCreate`, since this crate deliberately has no such class
/// to hook (see RUST.md's E3 entry on the two-Java-classes floor).
fn ensure_logger() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
#[cfg(target_os = "android")]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag("android-shell"),
);
});
}
// The parameters are spelled as their Java types, not as `JObject`: the
// macro encodes each argument into the exported symbol's JNI signature
// (and JNI resolves `Java_...` names *by* that signature), so a generic
// `JObject` here would export `(Ljava/lang/Object;...)` against a Java
// method actually declared `(Landroid/app/Activity;...)` -- two different
// symbols that never resolve to each other, silently, with no compiler
// error on either side. `android.app.Activity` etc. have no dedicated
// Rust wrapper in this crate, so they fall back to plain `JObject` in the
// implementation functions below (the "Built-in Types" note in
// `native_method!`'s docs).
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.MainActivity",
static extern fn native_handle_intent(activity: android.app.Activity, intent: android.content.Intent) -> (),
error_policy = LogErrorAndDefault,
};
/// `MainActivity.nativeHandleIntent` -- called from `onCreate` and
/// `onNewIntent`. See `share::handle_intent` for what an intent can mean.
fn native_handle_intent<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
activity: JObject<'local>,
intent: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &activity)?;
share::handle_intent(env, &activity, &intent)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_sync(context: android.content.Context) -> (),
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeSync` -- called both from `MainActivity` (an
/// enrollment may have just landed) and from `NotificationService.sync`
/// itself. See `notify::sync`.
fn native_sync<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
context: JObject<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &context)?;
notify::sync(env, &context)
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_start_command(service: android.app.Service) -> jint,
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnStartCommand`. See `notify::on_start_command`.
fn native_on_start_command<'local>(
env: &mut Env<'local>,
_class: JClass<'local>,
service: JObject<'local>,
) -> Result<jint, jni::errors::Error> {
ensure_logger();
jcall::remember_class_loader(env, &service)?;
Ok(notify::on_start_command(env, service))
}
const _: NativeMethod = native_method! {
java_type = "com.example.aiapp.shell.NotificationService",
static extern fn native_on_destroy() -> (),
error_policy = LogErrorAndDefault,
};
/// `NotificationService.nativeOnDestroy`. See `notify::on_destroy`.
fn native_on_destroy<'local>(
_env: &mut Env<'local>,
_class: JClass<'local>,
) -> Result<(), jni::errors::Error> {
ensure_logger();
notify::on_destroy();
Ok(())
}