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>
153 lines
6.6 KiB
Rust
153 lines
6.6 KiB
Rust
//! Thin wrappers around the five `Env` calls this crate makes constantly
|
|
//! (a class name, a method name and a signature, all as plain `&str`).
|
|
//!
|
|
//! `jni` 0.22 wants a class or method *name* as `AsRef<JNIStr>` (its own
|
|
//! modified-UTF-8 type; `JNIString::new` is the runtime conversion, used
|
|
//! here uniformly rather than switching to the compile-time `jni_str!`
|
|
//! literal macro call by call -- these are a handful of short, one-off
|
|
//! lookups, not a hot loop, so the difference is not worth two code paths
|
|
//! for the same thing) and a *signature* as a parsed `MethodSignature`/
|
|
//! `FieldSignature`, which is why those go through
|
|
//! `RuntimeMethodSignature`/`RuntimeFieldSignature::from_str` instead: the
|
|
//! parsed form is what lets these calls skip re-validating the signature
|
|
//! against the arguments on every call, which is the whole reason `jni`
|
|
//! moved to it.
|
|
//!
|
|
//! **The classloader gotcha, found by testing (2026-09-05).** A class
|
|
//! lookup by name (`find_class`, `new_object`, `call_static_method`,
|
|
//! `get_static_field` -- anything that resolves a *class*, as opposed to
|
|
//! `call_method` on an object it already has, which needs no such lookup)
|
|
//! defaults to `FindClass`'s ordinary search when it cannot find the
|
|
//! calling thread a classloader through `Thread.getContextClassLoader()`.
|
|
//! That default is fine on a thread the JVM itself started -- an
|
|
//! `onCreate`/`onStartCommand` callback -- but every one of these calls
|
|
//! from `android-shell`'s own background thread (the notification
|
|
//! follow-loop, the share upload) is running on a thread *Rust* spawned
|
|
//! and attached with `JavaVM::attach_current_thread`, which the platform
|
|
//! never gave an app classloader. Framework classes
|
|
//! (`android.app.Notification$Builder`, ...) still resolve, because they
|
|
//! are reachable from the bootstrap loader `FindClass` falls back to --
|
|
//! `androidx.core.app.NotificationManagerCompat` is not, since it is
|
|
//! packaged inside this app's own APK. The failure was
|
|
//! `Error::NoClassDefFound`, logged by `notify::show`'s `LogErrorAndDefault`
|
|
//! as "failed to resolve Java class ... (class not found or linkage
|
|
//! error)" -- on a real device this reads as "the notification silently
|
|
//! never arrives," since the whole call is inside the follow loop and the
|
|
//! ongoing foreground notification (built on the main thread, in
|
|
//! `try_start`, before the background thread exists) posts fine either
|
|
//! way. `remember_class_loader` caches the app's own `ClassLoader` the
|
|
//! first time any entry point has a `Context` to ask, and every class
|
|
//! lookup below goes through it explicitly via `LoaderContext::Loader`
|
|
//! rather than the thread-dependent default -- so it is correct on the
|
|
//! main thread and on this crate's own background threads alike.
|
|
|
|
use jni::Env;
|
|
use jni::errors::Result;
|
|
use jni::objects::{JClass, JClassLoader, JObject, JValue, JValueOwned};
|
|
use jni::refs::{Global, LoaderContext};
|
|
use jni::signature::{RuntimeFieldSignature, RuntimeMethodSignature};
|
|
use jni::strings::JNIString;
|
|
use std::sync::OnceLock;
|
|
|
|
static CLASS_LOADER: OnceLock<Global<JClassLoader<'static>>> = OnceLock::new();
|
|
|
|
/// Caches `context`'s own `ClassLoader`, the first time this is called.
|
|
/// Cheap to call from every entry point that has a `Context` on hand
|
|
/// (`MainActivity`'s and `NotificationService`'s all do): later calls are
|
|
/// a `OnceLock::get` and nothing else.
|
|
pub fn remember_class_loader(env: &mut Env, context: &JObject) -> Result<()> {
|
|
if CLASS_LOADER.get().is_some() {
|
|
return Ok(());
|
|
}
|
|
// context.getClass().getClassLoader() -- resolved via `call_method` on
|
|
// real objects throughout, so this needs no class-name lookup of its
|
|
// own and has nothing to bootstrap.
|
|
let class_obj = call_method(env, context, "getClass", "()Ljava/lang/Class;", &[])?.l()?;
|
|
let loader_obj = call_method(
|
|
env,
|
|
&class_obj,
|
|
"getClassLoader",
|
|
"()Ljava/lang/ClassLoader;",
|
|
&[],
|
|
)?
|
|
.l()?;
|
|
let loader = env.cast_local::<JClassLoader>(loader_obj)?;
|
|
let global = env.new_global_ref(&loader)?;
|
|
// Lost the race with another entry point calling this concurrently --
|
|
// both loaders name the same app, so either one is fine and there is
|
|
// nothing to reconcile.
|
|
let _ = CLASS_LOADER.set(global);
|
|
Ok(())
|
|
}
|
|
|
|
/// Resolves `name` (slash-separated, e.g. `androidx/core/app/NotificationCompat`)
|
|
/// through the cached app classloader when one has been remembered, and
|
|
/// through the ordinary default otherwise -- which is every call made
|
|
/// before any entry point has run, and is also correct for a main-thread
|
|
/// caller, so there is no case this makes worse.
|
|
fn resolve_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
|
match CLASS_LOADER.get() {
|
|
Some(loader) => {
|
|
let binary_name = name.replace('/', ".");
|
|
LoaderContext::Loader(loader).load_class(env, JNIString::new(&binary_name), true)
|
|
}
|
|
None => env.find_class(JNIString::new(name)),
|
|
}
|
|
}
|
|
|
|
pub fn find_class<'local>(env: &mut Env<'local>, name: &str) -> Result<JClass<'local>> {
|
|
resolve_class(env, name)
|
|
}
|
|
|
|
/// A new Java string as a plain `JObject` -- what every call site here
|
|
/// wants it as (`JValue::Object` takes `&JObject`, not `&JString`, and
|
|
/// `JString: Into<JObject>` is the documented way across).
|
|
pub fn jstr_obj<'local>(env: &mut Env<'local>, text: impl AsRef<str>) -> Result<JObject<'local>> {
|
|
Ok(env.new_string(text)?.into())
|
|
}
|
|
|
|
pub fn new_object<'local>(
|
|
env: &mut Env<'local>,
|
|
class: &str,
|
|
sig: &str,
|
|
args: &[JValue],
|
|
) -> Result<JObject<'local>> {
|
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
let class = resolve_class(env, class)?;
|
|
env.new_object(class, sig.method_signature(), args)
|
|
}
|
|
|
|
pub fn call_method<'local>(
|
|
env: &mut Env<'local>,
|
|
obj: &JObject,
|
|
method: &str,
|
|
sig: &str,
|
|
args: &[JValue],
|
|
) -> Result<JValueOwned<'local>> {
|
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
env.call_method(obj, JNIString::new(method), sig.method_signature(), args)
|
|
}
|
|
|
|
pub fn call_static_method<'local>(
|
|
env: &mut Env<'local>,
|
|
class: &str,
|
|
method: &str,
|
|
sig: &str,
|
|
args: &[JValue],
|
|
) -> Result<JValueOwned<'local>> {
|
|
let sig = RuntimeMethodSignature::from_str(sig)?;
|
|
let class = resolve_class(env, class)?;
|
|
env.call_static_method(class, JNIString::new(method), sig.method_signature(), args)
|
|
}
|
|
|
|
pub fn get_static_field<'local>(
|
|
env: &mut Env<'local>,
|
|
class: &str,
|
|
field: &str,
|
|
sig: &str,
|
|
) -> Result<JValueOwned<'local>> {
|
|
let sig = RuntimeFieldSignature::from_str(sig)?;
|
|
let class = resolve_class(env, class)?;
|
|
env.get_static_field(class, JNIString::new(field), sig.field_signature())
|
|
}
|