diff --git a/iris/android-app/Cargo.lock b/iris/android-app/Cargo.lock index 4f1a070..592dbfd 100644 --- a/iris/android-app/Cargo.lock +++ b/iris/android-app/Cargo.lock @@ -1768,9 +1768,11 @@ dependencies = [ "client-core", "event-model", "iris", + "libc", "log", "serde_json", "tabs-ui", + "tokio", "transcript-ui", ] diff --git a/iris/android-app/Cargo.toml b/iris/android-app/Cargo.toml index 776cbb4..d0551bd 100644 --- a/iris/android-app/Cargo.toml +++ b/iris/android-app/Cargo.toml @@ -32,6 +32,23 @@ transcript-ui = { path = "../transcript-ui", optional = true } client-core = { path = "../../client-core", optional = true } event-model = { path = "../../event-model", optional = true } serde_json = { version = "1", features = ["float_roundtrip"], optional = true } +# P0's bench build only (docs/RUST.md): `getrusage(RUSAGE_SELF)` for +# process CPU time, matching `libc::getrusage`'s mention in that box over +# parsing `/proc/self/stat` by hand and assuming `USER_HZ`. Already in the +# workspace's own dependency tree transitively (`iris/Cargo.lock`, pinned +# at 0.2.179) -- this makes it a direct dependency at the same version +# rather than a second, possibly-drifting resolution. +libc = { version = "0.2.179", optional = true } +# P0's bench build only: the scroll animation and the streaming phase are +# both a sequence of `sleep`s inside the async task `rsc.spawn_task` already +# runs on iris's own tokio runtime (`iris/src/task.rs`'s `Tasks::init`), and +# the battery sampler is a second, concurrent task on that same runtime +# (`tokio::spawn`) -- so this crate needs `tokio` directly rather than only +# through `iris`. `rt`+`time` only: no I/O, no macros, nothing this crate +# doesn't call. Version matches the one `iris`'s own dependency tree already +# resolves to (`iris/Cargo.lock`), so there is one copy of the runtime, not +# two. +tokio = { version = "1.53.1", features = ["rt", "time"], optional = true } [features] default = ["tabs-screen"] @@ -41,6 +58,14 @@ transcript-screen = ["dep:transcript-ui", "dep:client-core", "dep:event-model", # instead of SwiftShader's software Vulkan. See `iris/Cargo.toml`'s own doc # on the feature this forwards to. force-gles = ["iris/force-gles"] +# P0's iris half (docs/RUST.md, docs/AGENTS.md's "The rigs"): the same +# checked-in fixture, scroll loop and streaming phase the Compose `bench` +# build type drives, run here against `transcript-ui`'s real screen with no +# server. Depends on `transcript-screen` for `transcript-ui`/`client-core`/ +# `event-model` -- `lib.rs`'s `ActiveClient` selection gives this feature +# priority over `transcript-screen`'s own `TranscriptClient` when both are +# listed, which is how this crate's build command names both explicitly. +bench = ["transcript-screen", "dep:libc", "dep:tokio"] [profile.release] panic = "abort" diff --git a/iris/android-app/app/build.gradle b/iris/android-app/app/build.gradle index 4af67aa..b1f18e9 100644 --- a/iris/android-app/app/build.gradle +++ b/iris/android-app/app/build.gradle @@ -19,9 +19,39 @@ android { versionName = "1.0" } + // A release build must be signed, and the key is per machine rather than per repo -- same + // reasoning and the same key as `app/build-apk.sh` (the Compose app): it is what a phone + // recognises the app by, and a secret never lives in a checkout (the mount is shared with an + // untrusted VM). `build-apk.sh` generates this key once and points at it through the + // environment; without it a release build here is unsigned, which is fine for everything + // except installing. + def keystore = System.getenv("AI_APP_KEYSTORE") + signingConfigs { + if (keystore != null) { + release { + storeFile = file(keystore) + storePassword = System.getenv("AI_APP_KEYSTORE_PASSWORD") + keyAlias = "ai-app" + keyPassword = storePassword + } + } + } + buildTypes { debug { } + // P0's iris half (docs/RUST.md's P0 box): the build a phone actually runs. The `.so` + // itself is built separately with `cargo ndk --release --features "transcript-screen + // force-gles bench"` straight into src/main/jniLibs/ (this crate's own Cargo.toml) -- + // Gradle here only packages and signs whatever is already there, the same division as the + // debug/tabs-screen build this project started with. `applicationIdSuffix` keeps it + // installable beside a debug build of the tabs demo rather than replacing it. + release { + applicationIdSuffix ".bench" + if (keystore != null) { + signingConfig = signingConfigs.release + } + } } compileOptions { diff --git a/iris/android-app/build.rs b/iris/android-app/build.rs index 18704fe..f6cb3fb 100644 --- a/iris/android-app/build.rs +++ b/iris/android-app/build.rs @@ -22,6 +22,14 @@ fn main() { if std::env::var_os("CARGO_FEATURE_TRANSCRIPT_SCREEN").is_none() { return; } + // P0's bench build (docs/RUST.md) opens the checked-in fixture with no + // server at all -- `bench_client.rs` never references the `pinned` + // module this generates, so requiring a live server's host/port/token/ + // CA to build it (as plain `transcript-screen` does, below) would be a + // pointless requirement for a build that talks to nothing. + if std::env::var_os("CARGO_FEATURE_BENCH").is_some() { + return; + } println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_HOST"); println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_PORT"); println!("cargo:rerun-if-env-changed=AI_APP_TRANSCRIPT_TOKEN"); diff --git a/iris/android-app/src/bench_client.rs b/iris/android-app/src/bench_client.rs new file mode 100644 index 0000000..2b8c755 --- /dev/null +++ b/iris/android-app/src/bench_client.rs @@ -0,0 +1,418 @@ +//! P0's iris half (docs/RUST.md's P0 box, docs/AGENTS.md's "The rigs"): +//! the same fixture, scroll loop and streaming phase the Compose `bench` +//! build type's `BenchRun.kt`/`BenchFixture.kt` drive, run here against +//! `transcript-ui`'s real screen with no server -- a frame-time comparison +//! that measures the renderer rather than the data or the network. +//! +//! **Reuses `transcript_client.rs`'s shape** (folded items, a full +//! `transcript_ui::build_tree` rebuild per event) with the network half +//! replaced by the checked-in fixture, embedded with `include_str!` -- +//! `app/bench-fixture/assets/transcript.jsonl`, 1,915,760 bytes, generated +//! by `app/bench-fixture/generate.py` and never a real transcript (that +//! file's own README). The first 3,200 lines are the opening backlog, +//! folded once through `client_core::transcript_fold::fold_page` exactly +//! as a real `/transcript` page would be; the remaining ~400 are the +//! streaming tail, replayed one at a time through `fold_event` -- the same +//! fold path a live SSE reply arrives on -- by the "Run benchmark" +//! control below. + +use crate::bench_jni::PlatformHandle; +use android_view::jni::{JavaVM, objects::GlobalRef}; +use client_core::transcript_fold::{TranscriptItem, fold_event, fold_page, group_tool_runs}; +use event_model::SeqEvent; +use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState}; +use iris::prelude::*; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +/// bench-fixture/README.md: the first `BACKLOG_COUNT` non-blank lines are +/// the opening window; the rest are the streaming tail. Kept in sync with +/// `BenchFixture.kt`'s identical constant by hand -- both read the same +/// checked-in file, so a mismatch would only mean the two apps' bench +/// builds open a different split of it, not a wrong-vs-right answer. +const BACKLOG_COUNT: usize = 3200; + +/// `BenchRun.kt`'s own constants -- kept identical so the two apps' bench +/// runs are the same gesture and the same load, which is the entire point +/// of a shared fixture and a shared scripted loop (P0's pass condition). +const CYCLES: usize = 6; +const SWIPE_PX: f32 = 900.0; +const SWIPE_MS: u64 = 200; +const SWIPE_PAUSE_MS: u64 = 500; +const STREAM_EVENTS_PER_SEC: u64 = 20; +const STREAM_SECONDS: u64 = 20; +/// One animation step's target cadence -- close enough to 60Hz that a +/// `List::scroll` swipe is many small moves rather than one jump, so +/// frames are actually rendered along the way (the point of animating it +/// at all rather than calling `scroll` once per swipe). +const ANIM_STEP_MS: u64 = 16; + +const FIXTURE_JSONL: &str = include_str!("../../../app/bench-fixture/assets/transcript.jsonl"); + +pub struct BenchClient { + ui_state: AndroidUiState, + content: WeakWidget, + report_display: WeakWidget, + screen: Option, + items: Vec, + /// The events not yet streamed -- consumed by `start_benchmark`'s own + /// clone, kept here only as the source a second run would need (the + /// button can be pressed more than once; `running` just stops overlap, + /// not repeat). + stream_tail: Vec, + platform: Option>, + last_report: Option, + running: bool, +} + +impl HasAndroidUiState for BenchClient { + fn android_state(&self) -> &AndroidUiState { + &self.ui_state + } + fn android_state_mut(&mut self) -> &mut AndroidUiState { + &mut self.ui_state + } +} + +/// Parses the fixture once: `serde_json::Value`s for the backlog +/// (`fold_page` takes a page of raw wire JSON, same as a real +/// `/transcript` response) and folded `SeqEvent`s for the tail (`fold_event` +/// takes one live wire event at a time, same as a real SSE frame). +fn parse_fixture() -> (Vec, Vec) { + let lines: Vec<&str> = FIXTURE_JSONL + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + let mut backlog = Vec::with_capacity(BACKLOG_COUNT.min(lines.len())); + let mut stream_tail = Vec::new(); + for (i, line) in lines.iter().enumerate() { + let value: serde_json::Value = + serde_json::from_str(line).expect("bench fixture is generated JSON, always valid"); + if i < BACKLOG_COUNT { + backlog.push(value); + } else { + let event: SeqEvent = serde_json::from_value(value) + .expect("bench fixture event matches event-model's SeqEvent"); + stream_tail.push(event); + } + } + (backlog, stream_tail) +} + +fn placeholder(rsc: &mut Rsc, message: &str) -> StrongWidget { + wtext(message.to_string()) + .color(Color::WHITE) + .wrap(true) + .pad(16) + .add_strong(rsc) + .any() +} + +/// `getrusage(RUSAGE_SELF)`'s user+system time, in ms -- `None` only if +/// the syscall itself fails, which UI_RULES.md's "never present an +/// inferred value as a measured one" says to keep apart from a real (and +/// here, impossible) zero. +fn process_cpu_ms() -> Option { + // SAFETY: `rusage` is a plain-old-data struct `getrusage` fully + // initialises on success; on failure it is never read. + unsafe { + let mut usage: libc::rusage = std::mem::zeroed(); + if libc::getrusage(libc::RUSAGE_SELF, &mut usage) != 0 { + return None; + } + let user_ms = usage.ru_utime.tv_sec as u64 * 1000 + usage.ru_utime.tv_usec as u64 / 1000; + let sys_ms = usage.ru_stime.tv_sec as u64 * 1000 + usage.ru_stime.tv_usec as u64 / 1000; + Some(user_ms + sys_ms) + } +} + +/// `VmHWM` from `/proc/self/status` -- the process's peak RSS since it +/// started, in kB. Same source `BenchRun.kt`'s `peakRssLine` reads, so the +/// two reports' numbers mean the same thing. +fn peak_rss_kb() -> Option { + std::fs::read_to_string("/proc/self/status") + .ok()? + .lines() + .find_map(|line| line.strip_prefix("VmHWM:")) + .and_then(|rest| rest.trim().strip_suffix("kB")) + .and_then(|n| n.trim().parse().ok()) +} + +fn battery_line(samples: &[i32]) -> String { + if samples.is_empty() { + return " battery current: unavailable on this device".to_string(); + } + let mean = samples.iter().map(|&v| v as i64).sum::() / samples.len() as i64; + let min = samples.iter().min().unwrap(); + let max = samples.iter().max().unwrap(); + format!( + " battery current: mean {mean}\u{b5}A over {} samples (min {min}, max {max})", + samples.len() + ) +} + +impl AndroidAppState for BenchClient { + fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc) -> Self { + let content = WidgetPtr::new().add(rsc); + let loading = placeholder(rsc, "Loading fixture..."); + content(rsc).set(loading); + + let report_display = wtext("") + .editable(EditMode::MultiLine) + .text_align(Align::LEFT) + .wrap(true) + .size(14) + .color(Color::WHITE) + .attr::(()) + .label("Benchmark report") + .add(rsc); + + let controls = bench_controls(rsc); + let tree = ( + controls, + content.height(rest(2)), + report_display.height(rest(1)).pad(8), + ) + .span(Dir::DOWN) + .add_strong(rsc) + .any(); + ui_state.set_root(tree); + + let mut client = Self { + ui_state, + content, + report_display, + screen: None, + items: Vec::new(), + stream_tail: Vec::new(), + platform: None, + last_report: None, + running: false, + }; + + let (backlog, stream_tail) = parse_fixture(); + client.stream_tail = stream_tail; + match fold_page(&backlog) { + Ok(items) => { + client.items = items; + client.rebuild_transcript(rsc); + } + Err(message) => { + client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}")) + } + } + client + } + + fn platform_ready(&mut self, _rsc: &mut AndroidRsc, vm: JavaVM, view: GlobalRef) { + self.platform = Some(Arc::new(PlatformHandle::new(vm, view))); + } + + fn back_pressed(&mut self, _rsc: &mut AndroidRsc, _render: &mut UiRenderState) -> bool { + false + } +} + +type Rsc = AndroidRsc; + +fn bench_controls(rsc: &mut Rsc) -> WeakWidget { + let run_rect = rect(Color::rgb(40, 70, 40)) + .on( + CursorSense::click(), + |ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| { + ctx.state.start_benchmark(rsc); + }, + ) + .label("Run benchmark"); + let run = ( + run_rect, + wtext("Run benchmark").size(18).text_align(Align::CENTER), + ) + .stack() + .pad(8) + .add(rsc); + + let copy_rect = rect(Color::rgb(50, 50, 60)) + .on( + CursorSense::click(), + |ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| { + ctx.state.copy_report(); + }, + ) + .label("Copy report"); + let copy = ( + copy_rect, + wtext("Copy report").size(18).text_align(Align::CENTER), + ) + .stack() + .pad(8) + .add(rsc); + + (run, copy).span(Dir::RIGHT).height(56).add(rsc) +} + +impl BenchClient { + fn show_message(&mut self, rsc: &mut Rsc, message: &str) { + let widget = placeholder(rsc, message); + (self.content)(rsc).set(widget); + self.screen = None; + } + + fn rebuild_transcript(&mut self, rsc: &mut Rsc) { + let rows = group_tool_runs(&self.items); + let (screen, tree) = transcript_ui::build_tree(rsc, rows); + (self.content)(rsc).set(tree); + self.screen = Some(screen); + } + + fn copy_report(&mut self) { + let Some(report) = &self.last_report else { + log::info!("iris bench report: nothing to copy -- run the benchmark first"); + return; + }; + let Some(platform) = &self.platform else { + log::info!("iris bench report: no platform handle, can't reach the clipboard"); + return; + }; + if platform.copy_to_clipboard("iris bench report", report) { + log::info!("iris bench report: copied to clipboard"); + } else { + log::info!("iris bench report: clipboard copy failed"); + } + } + + /// P0's scripted run: `BenchRun.kt`'s scroll loop, then its streaming + /// phase, then the report -- run in-process for the same reason that + /// file's own doc gives (no usable system tracing on a real phone, no + /// agent that can drive one). + fn start_benchmark(&mut self, rsc: &mut Rsc) { + if self.running { + log::info!("iris bench report: already running"); + return; + } + self.running = true; + self.android_state_mut().frame_report.reset(); + self.report_display.edit(rsc).set("Running benchmark..."); + + let redraw = rsc.tasks.redraw_handle(); + let platform = self.platform.clone(); + let stream_tail = self.stream_tail.clone(); + let cpu_start = process_cpu_ms(); + + rsc.spawn_task(async move |mut ctx| { + // The swipe loop: two drags toward newer content, two back -- + // a cycle returns to where it started, so the whole loop + // measures steady-state scrolling. `BenchRun.kt`'s own + // comment on this shape. + for _ in 0..CYCLES { + for delta in [SWIPE_PX, SWIPE_PX, -SWIPE_PX, -SWIPE_PX] { + animate_scroll(&mut ctx, &redraw, delta, SWIPE_MS).await; + tokio::time::sleep(Duration::from_millis(SWIPE_PAUSE_MS)).await; + } + } + + // Pinned to the newest end before streaming starts, matching + // `stream-bench.sh`'s "Jump to latest" tap. + ctx.update(|state: &mut BenchClient, rsc| { + if let Some(screen) = &state.screen { + (screen.list)(rsc).jump_to_end(); + } + }); + redraw.request_redraw(); + + // The battery sampler runs concurrently with the streaming + // phase, once a second, the same cadence `BatterySampler` uses + // on the Compose side -- via its own JNI-attached thread, not + // `ctx.update`, since a sample needs no widget-tree access. + let sampler_done = Arc::new(AtomicBool::new(false)); + let samples = Arc::new(std::sync::Mutex::new(Vec::::new())); + let sampler = platform.clone().map(|platform| { + let done = sampler_done.clone(); + let samples = samples.clone(); + tokio::spawn(async move { + while !done.load(Ordering::Relaxed) { + if let Some(value) = platform.battery_current_ua() { + samples.lock().unwrap().push(value); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + }) + }); + + let total = (STREAM_EVENTS_PER_SEC * STREAM_SECONDS) as usize; + let mut sent = 0usize; + for event in stream_tail.into_iter().take(total) { + ctx.update(move |state: &mut BenchClient, rsc| { + state.items = fold_event(&state.items, &event); + state.rebuild_transcript(rsc); + }); + redraw.request_redraw(); + sent += 1; + tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await; + } + // Lets the last few deltas land and draw before the report is + // read -- `BenchRun.kt`'s own closing delay. + tokio::time::sleep(Duration::from_millis(300)).await; + + sampler_done.store(true, Ordering::Relaxed); + if let Some(sampler) = sampler { + let _ = sampler.await; + } + let battery = battery_line(&samples.lock().unwrap()); + let cpu_line = match (cpu_start, process_cpu_ms()) { + (Some(start), Some(end)) => { + format!(" process CPU time over this run: {}ms", end.saturating_sub(start)) + } + _ => " process CPU time over this run: unavailable".to_string(), + }; + let rss_line = match peak_rss_kb() { + Some(kb) => format!(" peak RSS: {kb}kB"), + None => " peak RSS: unavailable (/proc/self/status unreadable)".to_string(), + }; + + ctx.update(move |state: &mut BenchClient, rsc| { + state.running = false; + let scroll_line = format!( + " scroll: {CYCLES} cycles ({} swipes), streamed {sent}/{total} fixture events", + CYCLES * 4 + ); + let frames_line = match state.android_state().frame_report.report() { + Some(stats) => format!("{stats}"), + None => "no frames recorded".to_string(), + }; + let report = format!( + "iris bench report\n{frames_line}\n{scroll_line}\n{cpu_line}\n{rss_line}\n{battery}" + ); + log::info!("iris bench report: {report}"); + state.report_display.edit(rsc).set(&report); + state.last_report = Some(report); + }); + redraw.request_redraw(); + }); + } +} + +/// Moves `List::scroll` by `total_px` over `duration_ms`, in ~60Hz steps, +/// so the swipe is many rendered frames rather than one jump -- the same +/// shape `animateScrollBy(SWIPE_PX, tween(SWIPE_MS))` gives on the Compose +/// side, in the one place the two backends have to differ (iris's `List` +/// has no built-in tween, so this drives it by hand). +async fn animate_scroll( + ctx: &mut iris::task::TaskCtx, + redraw: &Arc, + total_px: f32, + duration_ms: u64, +) { + let steps = (duration_ms / ANIM_STEP_MS).max(1); + let step_px = total_px / steps as f32; + for _ in 0..steps { + ctx.update(move |state: &mut BenchClient, rsc| { + if let Some(screen) = &state.screen { + (screen.list)(rsc).scroll(step_px); + } + }); + redraw.request_redraw(); + tokio::time::sleep(Duration::from_millis(ANIM_STEP_MS)).await; + } +} diff --git a/iris/android-app/src/bench_jni.rs b/iris/android-app/src/bench_jni.rs new file mode 100644 index 0000000..0a5ae4b --- /dev/null +++ b/iris/android-app/src/bench_jni.rs @@ -0,0 +1,134 @@ +//! JNI calls the `bench` feature needs that go through the shell's own +//! Java side rather than anything `iris`/`android-view` already wraps: +//! `BatteryManager.getIntProperty(BATTERY_PROPERTY_CURRENT_NOW)` for the +//! per-second battery sample, and `ClipboardManager.setPrimaryClip` for +//! the "Copy report" control (P0's iris half, docs/RUST.md). Neither is +//! part of `android_view::context`'s own `Context`/`Resources` wrappers +//! (that file's own `// TODO: more methods?`), so this calls them +//! directly rather than growing that crate's wrapper for two one-off +//! calls this crate alone needs. +//! +//! Holds its own `JavaVM` + `GlobalRef` to the view (handed in through +//! [`iris::android::AndroidAppState::platform_ready`]) so it can attach +//! whichever thread calls it -- the battery sampler runs on a background +//! tokio task, not the UI thread the rest of `IrisViewPeer`'s JNI calls +//! run on. `JavaVM::attach_current_thread` is safe to call from a thread +//! already attached (the `jni` crate detects it and does not double +//! attach), so no caller here needs to know or care which thread it is. + +use android_view::jni::{ + JNIEnv, JavaVM, + objects::{GlobalRef, JObject, JValue}, +}; + +/// `android.os.BatteryManager.BATTERY_PROPERTY_CURRENT_NOW` -- not exposed +/// as a constant anywhere reachable without the Android SDK jar, so named +/// here with its source rather than left as a bare `2`. +const BATTERY_PROPERTY_CURRENT_NOW: i32 = 2; + +pub struct PlatformHandle { + vm: JavaVM, + view: GlobalRef, +} + +impl PlatformHandle { + pub fn new(vm: JavaVM, view: GlobalRef) -> Self { + Self { vm, view } + } + + fn context<'e>(&self, env: &mut JNIEnv<'e>) -> Option> { + env.call_method( + self.view.as_obj(), + "getContext", + "()Landroid/content/Context;", + &[], + ) + .ok()? + .l() + .ok() + } + + fn system_service<'e>( + &self, + env: &mut JNIEnv<'e>, + context: &JObject<'e>, + name: &str, + ) -> Option> { + let jname = env.new_string(name).ok()?; + env.call_method( + context, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(jname.as_ref())], + ) + .ok()? + .l() + .ok() + } + + /// One sample of `BATTERY_PROPERTY_CURRENT_NOW`, in microamps. `None` + /// on any JNI failure, on a device with no `BatteryManager` service, + /// or when the platform itself answers "not supported" -- `0` or + /// `Integer.MIN_VALUE` are both documented SDK answers for that, and + /// both would read as a real (and wrong) measurement if folded into an + /// average rather than named apart. UI_RULES.md: never present an + /// inferred value as a measured one. + pub fn battery_current_ua(&self) -> Option { + let mut guard = self.vm.attach_current_thread().ok()?; + let env: &mut JNIEnv = &mut guard; + let context = self.context(env)?; + let battery_manager = self.system_service(env, &context, "batterymanager")?; + let value = env + .call_method( + &battery_manager, + "getIntProperty", + "(I)I", + &[JValue::Int(BATTERY_PROPERTY_CURRENT_NOW)], + ) + .ok()? + .i() + .ok()?; + if value == 0 || value == i32::MIN { + None + } else { + Some(value) + } + } + + /// Puts `text` on the system clipboard through `ClipboardManager` -- + /// `true` only if the whole JNI chain (service lookup, `ClipData`, + /// `setPrimaryClip`) succeeded. + pub fn copy_to_clipboard(&self, label: &str, text: &str) -> bool { + self.try_copy_to_clipboard(label, text).is_some() + } + + fn try_copy_to_clipboard(&self, label: &str, text: &str) -> Option<()> { + let mut guard = self.vm.attach_current_thread().ok()?; + let env: &mut JNIEnv = &mut guard; + let context = self.context(env)?; + let clipboard = self.system_service(env, &context, "clipboard")?; + let jlabel = env.new_string(label).ok()?; + let jtext = env.new_string(text).ok()?; + let clip = env + .call_static_method( + "android/content/ClipData", + "newPlainText", + "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;", + &[ + JValue::Object(jlabel.as_ref()), + JValue::Object(jtext.as_ref()), + ], + ) + .ok()? + .l() + .ok()?; + env.call_method( + &clipboard, + "setPrimaryClip", + "(Landroid/content/ClipData;)V", + &[JValue::Object(&clip)], + ) + .ok()?; + Some(()) + } +} diff --git a/iris/android-app/src/lib.rs b/iris/android-app/src/lib.rs index 6a8cc32..8825688 100644 --- a/iris/android-app/src/lib.rs +++ b/iris/android-app/src/lib.rs @@ -23,6 +23,18 @@ //! A build picks one screen or the other, never both, so `Client` and //! `TranscriptClient` are cfg-gated apart rather than switched at runtime -- //! there is no in-app navigation to switch *to* on either side yet. +//! +//! **`bench` feature (P0's iris half, docs/RUST.md):** a third +//! `AndroidAppState`, `bench_client::BenchClient`, on the same axis -- +//! `transcript_ui::build_tree` again, this time against the checked-in +//! fixture (`app/bench-fixture/assets/transcript.jsonl`) instead of a real +//! server, with a "Run benchmark" control that drives the same scroll loop +//! and streaming phase the Compose `bench` build type's `BenchRun.kt` +//! does. `bench` depends on `transcript-screen` (Cargo.toml) for +//! `transcript-ui`/`client-core`/`event-model`, so both features end up +//! enabled together -- `ActiveClient` below gives `bench` priority in that +//! case, the same way `transcript-screen` already takes priority over the +//! default `tabs-screen`. use android_view::{ Context, View, @@ -39,7 +51,11 @@ use iris::prelude::*; use log::LevelFilter; use std::ffi::c_void; -#[cfg(feature = "transcript-screen")] +#[cfg(feature = "bench")] +mod bench_client; +#[cfg(feature = "bench")] +mod bench_jni; +#[cfg(all(feature = "transcript-screen", not(feature = "bench")))] mod transcript_client; /// The app's `View` subclass, matching the Java side's package -- @@ -85,8 +101,10 @@ impl AndroidAppState for Client { #[cfg(not(feature = "transcript-screen"))] type ActiveClient = Client; -#[cfg(feature = "transcript-screen")] +#[cfg(all(feature = "transcript-screen", not(feature = "bench")))] type ActiveClient = transcript_client::TranscriptClient; +#[cfg(feature = "bench")] +type ActiveClient = bench_client::BenchClient; extern "system" fn new_view_peer<'local>( env: JNIEnv<'local>,