iris is the framework alone; the app is one crate in app-rust/

Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:36:38 -04:00
1 parent e9a6562dc6
commit 6d5a231f5c
100 files changed
+924 -3295

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
//! 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())
}