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>
212 lines
8.5 KiB
Rust
212 lines
8.5 KiB
Rust
//! The JNI half of `DevLogProvider`: reading this process's own log ring
|
|
//! for a `ContentProvider` that Dev Updater queries.
|
|
//!
|
|
//! **Why**: Iris runs these builds on a phone with no `adb`, and Android
|
|
//! forbids one app reading another's `logcat`, so nothing outside this
|
|
//! process can recover what it wrote. The app already keeps a bounded copy
|
|
//! (`crate::client::log_ring`); this is how the copy leaves the process. Dev
|
|
//! Updater is on the same phone, so handing it over needs no tunnel, no
|
|
//! token and no second enrolment -- and it is Dev Updater's own contract
|
|
//! rather than something invented here, so any app it delivers can do the
|
|
//! same (its `README.md`, "An app's own log").
|
|
//!
|
|
//! **Everything general stays in `client-core`** (AGENTS.md's sharing
|
|
//! rule). What is here is only what Android forces: the JNI boundary and
|
|
//! the Java class on the other side of it.
|
|
//!
|
|
//! Both entry points answer a **flat `String[]`** rather than a row of
|
|
//! typed columns. That is the whole of the JNI, and it is one array type
|
|
//! instead of three interleaved ones for a payload the provider is about
|
|
//! to hand back over binder as a `MatrixCursor` anyway; `DevLogProvider`
|
|
//! parses the two numeric fields. Kept flat rather than nested for the
|
|
//! same reason -- an array of arrays is four more JNI calls per line.
|
|
|
|
use android_view::jni::JNIEnv;
|
|
use android_view::jni::objects::{JClass, JObject, JString};
|
|
use android_view::jni::sys::{jlong, jobjectArray};
|
|
use std::sync::OnceLock;
|
|
|
|
/// How many `String`s each log line occupies in the flat answer:
|
|
/// `seq`, `t_ms`, `level`, `target`, `message`, in that order. The Java
|
|
/// side has the same constant, and the two are the one place the shape is
|
|
/// written down on each side.
|
|
///
|
|
/// Gated with its one reader: the tabs demo links no `client-core` and so
|
|
/// has no ring to lay out, and an ungated constant is a warning in that
|
|
/// build (`iris-android-app` without `transcript-screen`).
|
|
#[cfg(feature = "transcript-screen")]
|
|
const FIELDS_PER_LINE: usize = 5;
|
|
|
|
/// The authority the provider registered itself under, once it has been
|
|
/// created. `None` until then, which is a state worth being able to say:
|
|
/// a provider Android never instantiated and one that is answering look
|
|
/// the same from inside this process otherwise.
|
|
static AUTHORITY: OnceLock<String> = OnceLock::new();
|
|
|
|
/// Where this app's log can be read from, for the diagnostics pane.
|
|
///
|
|
/// The provider's own answer rather than one composed from the package
|
|
/// name here: what makes the line worth showing is that it names an
|
|
/// authority somebody can actually query, and only the provider knows it
|
|
/// registered.
|
|
#[cfg(feature = "bench")]
|
|
pub fn authority() -> Option<&'static str> {
|
|
AUTHORITY.get().map(String::as_str)
|
|
}
|
|
|
|
/// `DevLogProvider.nativeReady` -- the provider announcing the authority
|
|
/// it registered under and the app's private directory, from its own
|
|
/// `onCreate`.
|
|
///
|
|
/// The directory is taken here as well as in
|
|
/// `MainActivity.nativeSetFilesDir` because **the provider is often the
|
|
/// only thing running**: once the app has died, Dev Updater's query
|
|
/// starts the process for the provider alone, so no activity ever runs
|
|
/// and the panic hook's file would never be replayed into the ring. That
|
|
/// is precisely the run whose log is being asked for. Whichever of the
|
|
/// two arrives first does the replay; `set_crash_dir` deletes the file,
|
|
/// so the second finds nothing and says nothing.
|
|
///
|
|
/// # Safety
|
|
/// Called by the JVM with the arguments its `native` declaration names.
|
|
#[unsafe(no_mangle)]
|
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeReady(
|
|
mut env: JNIEnv,
|
|
_class: JClass,
|
|
authority: JString,
|
|
files_dir: JString,
|
|
) {
|
|
// Before the authority line, so the previous run's death is above the
|
|
// line announcing this one rather than buried under it.
|
|
#[cfg(feature = "transcript-screen")]
|
|
if let Some(dir) = string_arg(&mut env, &files_dir) {
|
|
crate::android::app_log::set_crash_dir(std::path::Path::new(&dir));
|
|
}
|
|
#[cfg(not(feature = "transcript-screen"))]
|
|
let _ = &files_dir;
|
|
let Some(authority) = string_arg(&mut env, &authority) else {
|
|
return;
|
|
};
|
|
log::info!("iris devlog: serving this app's log at content://{authority}");
|
|
let _ = AUTHORITY.set(authority);
|
|
}
|
|
|
|
/// One `String` argument, or `None` for a null or unreadable one.
|
|
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
|
|
if value.is_null() {
|
|
return None;
|
|
}
|
|
env.get_string(value).ok().map(Into::into)
|
|
}
|
|
|
|
/// `DevLogProvider.nativeStatus` -- `held`, `dropped`, `newest_seq`, as
|
|
/// three strings.
|
|
///
|
|
/// `newest_seq` is `-1` for a ring nothing has been written to, which is
|
|
/// what tells a reader holding a cursor that this process **restarted**:
|
|
/// the ring is in memory, so a new process starts again at zero and a
|
|
/// stale cursor would otherwise skip everything silently.
|
|
///
|
|
/// Exported by name rather than registered, matching this crate's other
|
|
/// activity-side natives: the mangled name is the whole of what a class
|
|
/// this app owns needs.
|
|
///
|
|
/// # Safety
|
|
/// Called by the JVM with the arguments its `native` declaration names.
|
|
#[unsafe(no_mangle)]
|
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeStatus(
|
|
mut env: JNIEnv,
|
|
_class: JClass,
|
|
) -> jobjectArray {
|
|
string_array(&mut env, &status_fields())
|
|
}
|
|
|
|
/// `DevLogProvider.nativeLinesSince` -- every held line with a sequence at
|
|
/// or after `since`, oldest first, [`FIELDS_PER_LINE`] strings each.
|
|
///
|
|
/// Inclusive of `since` because [`crate::client::log_ring::LogRing::since`]
|
|
/// is, and one definition of the cursor is what keeps the app's own
|
|
/// uploaded report and this provider describing the same lines.
|
|
///
|
|
/// # Safety
|
|
/// Called by the JVM with the arguments its `native` declaration names.
|
|
#[unsafe(no_mangle)]
|
|
pub extern "system" fn Java_dev_iris_android_demo_DevLogProvider_nativeLinesSince(
|
|
mut env: JNIEnv,
|
|
_class: JClass,
|
|
since: jlong,
|
|
) -> jobjectArray {
|
|
// A negative cursor is a caller asking for everything, not an error to
|
|
// take the app down over: the provider is a diagnostic.
|
|
string_array(&mut env, &line_fields(since.max(0) as u64))
|
|
}
|
|
|
|
/// The three status numbers, as the provider's row.
|
|
#[cfg(feature = "transcript-screen")]
|
|
fn status_fields() -> Vec<String> {
|
|
let ring = crate::client::log_ring::process_ring();
|
|
vec![
|
|
ring.len().to_string(),
|
|
ring.dropped().to_string(),
|
|
ring.newest_seq().map_or(-1, |seq| seq as i64).to_string(),
|
|
]
|
|
}
|
|
|
|
/// The tabs demo links no `client-core` and keeps no ring, so it holds
|
|
/// nothing and has never dropped anything -- which is the truth, not a
|
|
/// stand-in. The natives are still exported there, because a `native`
|
|
/// method Java declares and the library does not is an
|
|
/// `UnsatisfiedLinkError` the moment the class loads.
|
|
#[cfg(not(feature = "transcript-screen"))]
|
|
fn status_fields() -> Vec<String> {
|
|
vec!["0".to_string(), "0".to_string(), "-1".to_string()]
|
|
}
|
|
|
|
#[cfg(feature = "transcript-screen")]
|
|
fn line_fields(since: u64) -> Vec<String> {
|
|
let (lines, _next) = crate::client::log_ring::process_ring().since(since);
|
|
let mut fields = Vec::with_capacity(lines.len() * FIELDS_PER_LINE);
|
|
for line in lines {
|
|
fields.push(line.seq.to_string());
|
|
fields.push(line.at_ms.to_string());
|
|
fields.push(line.level.to_string());
|
|
fields.push(line.target);
|
|
fields.push(line.message);
|
|
}
|
|
fields
|
|
}
|
|
|
|
#[cfg(not(feature = "transcript-screen"))]
|
|
fn line_fields(_since: u64) -> Vec<String> {
|
|
Vec::new()
|
|
}
|
|
|
|
/// A Java `String[]` of those, or a null array if the JVM refused one.
|
|
///
|
|
/// Null rather than a panic across the JNI boundary: `DevLogProvider`
|
|
/// reads it as "the provider could not answer" and returns no cursor,
|
|
/// which Dev Updater already draws as a distinct state. Taking the app
|
|
/// down to report that its diagnostic is unavailable would be worse than
|
|
/// the diagnostic being unavailable.
|
|
fn string_array(env: &mut JNIEnv, fields: &[String]) -> jobjectArray {
|
|
let null = std::ptr::null_mut();
|
|
let Ok(class) = env.find_class("java/lang/String") else {
|
|
return null;
|
|
};
|
|
let Ok(array) = env.new_object_array(fields.len() as i32, class, JObject::null()) else {
|
|
return null;
|
|
};
|
|
for (index, field) in fields.iter().enumerate() {
|
|
let Ok(value) = env.new_string(field) else {
|
|
return null;
|
|
};
|
|
if env
|
|
.set_object_array_element(&array, index as i32, value)
|
|
.is_err()
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
array.into_raw()
|
|
}
|