Make the Rust client the sole app

This commit is contained in:
iris committed 2026-09-11 01:18:24 -04:00
1 parent a8602c1626
commit d8bb1699a8
230 files changed
+762 -27300

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
//! The platform half of this app's logging: what
//! `crate::client::log_ring` needs that only Android can supply, which is
//! `android_logger` as the logger to forward to and nothing else.
use crate::client::log_ring::{self, LogRing};
use std::{
fs, panic,
path::{Path, PathBuf},
sync::OnceLock,
};
/// Installs the in-process ring in front of Android's logger.
pub fn install(max_level: log::LevelFilter) {
let inner = android_logger::AndroidLogger::new(
android_logger::Config::default()
.with_max_level(max_level)
.with_tag("iris-android-app"),
);
if log_ring::install_process_logger(
Box::new(inner),
max_level,
iris::diagnostics::trace_enabled,
)
.is_err()
{
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
install_panic_hook();
}
pub fn ring() -> &'static LogRing {
log_ring::process_ring()
}
#[cfg(feature = "bench")]
pub fn diagnostics_line() -> String {
let where_to_read = match crate::android::devlog::authority() {
Some(authority) => format!("devlog provider: content://{authority}"),
None => "devlog provider: declared, not created yet".to_string(),
};
format!("{}\n{where_to_read}", ring().summary())
}
/// Where the panic hook leaves its report, under the app's private
/// directory. Read back and dropped by [`set_crash_dir`] on the next
/// start.
const CRASH_FILE: &str = "last-panic.txt";
/// Enough preceding log lines to explain a crash without evicting the next run.
const CRASH_CONTEXT_LINES: usize = 80;
const PREVIOUS_RUN_TARGET: &str = "previous_run";
static CRASH_PATH: OnceLock<PathBuf> = OnceLock::new();
/// Copies aborting panics into the device-readable log ring.
fn install_panic_hook() {
let previous = panic::take_hook();
panic::set_hook(Box::new(move |info| {
let where_at = match info.location() {
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
None => "an unknown location".to_string(),
};
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
let line = format!("iris panic at {where_at}: {message}");
log::error!("{line}");
if let Some(path) = CRASH_PATH.get() {
let context = ring()
.try_tail_text(CRASH_CONTEXT_LINES)
.unwrap_or_else(|| {
"(the log ring was locked as this run died; no context)".to_string()
});
let _ = fs::write(path, format!("{line}\n{context}"));
}
previous(info);
}));
}
/// Configures crash persistence and replays a report left by the previous run.
pub fn set_crash_dir(dir: &Path) {
let path = dir.join(CRASH_FILE);
if let Ok(previous) = fs::read_to_string(&path) {
// Delete first so a panic during replay cannot create a replay loop.
let _ = fs::remove_file(&path);
replay_crash(&previous);
}
let _ = CRASH_PATH.set(path);
}
/// Puts a previous run's report back in the ring: its context lines in
/// the order they happened, then the panic itself.
fn replay_crash(report: &str) {
let (panic_line, context) = report.split_once('\n').unwrap_or((report, ""));
for line in context.lines().filter(|line| !line.is_empty()) {
ring().push(log::Level::Info, PREVIOUS_RUN_TARGET, line.to_string());
}
log::error!(
"iris app log: the previous run died -- {}",
panic_line.trim()
);
}
+888
View File
@@ -0,0 +1,888 @@
use crate::android::bench_jni::PlatformHandle;
use crate::client::transcript_fold::{TranscriptItem, fold_event};
use crate::ui::{self, TranscriptScreen};
use android_view::jni::{JavaVM, objects::GlobalRef};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::{
fs, mem,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::{Duration, Instant},
};
const STREAM_EVENTS_PER_SEC: u64 = 20;
const STREAM_SECONDS: u64 = 20;
const LEGACY_CYCLES: usize = 6;
const FLING_VELOCITY_PX_S: f32 = 12_000.0;
const FLING_COUNT: usize = 8;
const FLING_SETTLE_CAP_MS: u64 = 3_000;
const FLING_PAUSE_MS: u64 = 300;
const TYPE_TEXT: &str = "Benchmarking this transcript screen requires unusually long, \
multisyllabic words so wrapping and reflow are properly exercised: internationalization, \
counterproductiveness, disproportionately, incomprehensibility, deinstitutionalization, \
uncharacteristically, overenthusiastically, misunderstanding, straightforwardness, \
telecommunications, and interdisciplinary collaboration all push a narrow composer field to \
wrap across several lines while the transcript above is pushed upward by the growing \
keyboard-adjacent box, which is exactly what a real reader typing a long message sees \
happening now!!!";
const TYPE_CHAR_MS: u64 = 50;
const KEYBOARD_CYCLES: usize = 5;
const KEYBOARD_WAIT_MS: u64 = 1_000;
const POLL_MS: u64 = 16;
const REPORT_MAX_HEIGHT_DP: f32 = 260.0;
pub struct BenchClient {
ui_state: AndroidUiState,
content: WeakWidget<WidgetPtr>,
report_display: WeakWidget<TextEdit>,
top_bar: WeakWidget<WidgetPtr>,
screen: Option<TranscriptScreen>,
items: Vec<TranscriptItem>,
stream_tail: Vec<SeqEvent>,
platform: Option<Arc<PlatformHandle>>,
last_report: Option<String>,
running: bool,
ime_state: Arc<Mutex<ImeState>>,
keyboard_was_visible: bool,
last_top_pad: f32,
}
#[derive(Default)]
struct ImeState {
visible: bool,
shown_events: u32,
hidden_events: u32,
}
impl HasAndroidUiState for BenchClient {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
fn process_cpu_ms() -> Option<u64> {
// 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 = 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)
}
}
fn peak_rss_kb() -> Option<u64> {
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::<i64>() / samples.len() as i64;
let (Some(min), Some(max)) = (samples.iter().min(), samples.iter().max()) else {
unreachable!("samples is non-empty, checked above");
};
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>) -> 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(PaintId::WHITE)
.attr::<Selectable>(())
.label("Benchmark report")
.add(rsc);
let top_bar = WidgetPtr::new().add(rsc);
let controls = bench_controls(rsc, 0.0);
top_bar(rsc).set(controls);
let tree = (
top_bar,
report_display
.pad(dp(8))
.max_height(dp(REPORT_MAX_HEIGHT_DP)),
content.height(rest(1)),
)
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(rsc, tree);
let font = rsc.ui.text.font_diagnostics();
log::info!(
"iris fonts: {} families found, default={:?} mono={:?}, resolved regular={:?} \
bold={:?} italic={:?} mono={:?}, icons={:?}",
font.families_found,
font.default_family,
font.default_mono_family,
font.regular_resolved,
font.bold_resolved,
font.italic_resolved,
font.mono_resolved,
font.icon_family,
);
let mut client = Self {
ui_state,
content,
report_display,
top_bar,
screen: None,
items: Vec::new(),
stream_tail: Vec::new(),
platform: None,
last_report: None,
running: false,
ime_state: Arc::new(Mutex::new(ImeState::default())),
keyboard_was_visible: false,
last_top_pad: 0.0,
};
match ui::fixture::build_screen(rsc) {
Ok((opened, tree)) => {
client.items = opened.items;
client.stream_tail = opened.stream_tail;
(client.content)(rsc).set(tree);
client.screen = Some(opened.screen);
}
Err(message) => {
client.show_message(rsc, &format!("Couldn't fold the bench fixture: {message}"))
}
}
client
}
fn platform_ready(&mut self, _rsc: &mut AndroidRsc<Self>, vm: JavaVM, view: GlobalRef) {
self.platform = Some(Arc::new(PlatformHandle::new(vm, view)));
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>) -> bool {
false
}
fn on_insets_changed(
&mut self,
rsc: &mut AndroidRsc<Self>,
insets: iris::android::WindowInsets,
) {
if insets.top != self.last_top_pad {
self.last_top_pad = insets.top;
let controls = bench_controls(rsc, insets.top);
(self.top_bar)(rsc).set(controls);
}
if let Some(screen) = &self.screen {
screen
.composer
.set_bottom_inset(rsc, insets.bottom.max(insets.ime_bottom));
}
// The platform's own answer, not `ime_bottom > 0.0` -- see
// `iris::android::WindowInsets::ime_bottom`. The height is still
// climbing while the keyboard slides in, so a frame or two of a
// real opening reads as "closed" when the boolean is inferred from
// it, and `shown_events`/`hidden_events` below count transitions.
let ime_visible = insets.ime_visible;
let mut ime = self.ime_state.lock().unwrap();
if ime_visible && !ime.visible {
ime.shown_events += 1;
}
if !ime_visible && ime.visible {
ime.hidden_events += 1;
}
ime.visible = ime_visible;
drop(ime);
if ime_visible && !self.keyboard_was_visible {
self.keyboard_was_visible = true;
let redraw = rsc.tasks.redraw_handle();
rsc.spawn_task(async move |mut ctx| {
tokio::time::sleep(Duration::from_millis(KEYBOARD_DIAGNOSTICS_DELAY_MS)).await;
ctx.update(|state: &mut BenchClient, rsc| {
state.capture_keyboard_diagnostics(rsc);
});
redraw.request_redraw();
});
} else if !ime_visible {
self.keyboard_was_visible = false;
}
}
}
const KEYBOARD_DIAGNOSTICS_DELAY_MS: u64 = 500;
type Rsc = AndroidRsc<BenchClient>;
/// What a report says about the `iris::input`/`iris::frame` trace, from
/// the flag read at the start of what is being reported and again at the
/// end.
///
/// Three answers rather than two. Those lines are default-off and the
/// switch that turns them on is on screen while a benchmark runs, so
/// "somebody moved it half way through" is a state that actually happens
/// -- and reported as either "on" or "off" it is a confident sentence
/// about a log that only covers part of the run. The "on" wording also
/// says what it costs, because a traced run fills the ring in seconds and
/// a reader looking at a log with nothing else in it should know why.
fn trace_line(at_start: bool, at_end: bool) -> String {
match (at_start, at_end) {
(true, true) => "input/frame trace: on (iris::input and iris::frame lines are in \
the app log, and a traced run fills the ring in seconds)"
.to_string(),
(false, false) => "input/frame trace: off".to_string(),
_ => "input/frame trace: switched during this run, so those lines cover only part \
of it"
.to_string(),
}
}
/// The header row's own backdrop -- see `bench_controls`'s doc comment on
/// why it needs one at all. A dark neutral rather than pure black
/// (`android::render::CLEAR_COLOR`) so the row reads as a distinct panel
/// instead of a hole in the background the buttons happen to float in.
const HEADER_SURFACE: Srgba8 = Srgba8::new(28, 28, 34, 255);
/// `top_pad` is the status-bar inset in physical pixels (0.0 until
/// `on_insets_changed` has run once) -- folded in here, rather than
/// exposing the unadded builder for a caller to `.pad()` itself, because
/// naming that builder's type at each call site is more machinery than a
/// top-of-screen padding number is worth.
const HEADER_TEXT: f32 = 18.0;
const HEADER_ROW_HEIGHT_DP: f32 = 56.0;
fn bench_controls(rsc: &mut Rsc, top_pad: f32) -> StrongWidget {
let run_rect = rect(Srgba8::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(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let copy_rect = rect(Srgba8::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.copy_report(rsc);
},
)
.label("Copy report");
let copy = (
copy_rect,
wtext("Copy report")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let diag_rect = rect(Srgba8::rgb(60, 45, 70))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.show_diagnostics(rsc);
},
)
.label("Diagnostics");
let diagnostics = (
diag_rect,
wtext("Diagnostics")
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
// A switch rather than a button, so its own appearance says which
// state it is in: the two `iris::input`/`iris::frame` targets are
// default-off (`iris::diagnostics`'s module doc) because a 120Hz
// session fills the 2000-line ring in seconds, so "is it on right
// now" is the question somebody has while looking at a log that is
// either full of trace or has none.
let tracing = iris::diagnostics::trace_enabled();
let trace_rect = rect(if tracing {
Srgba8::rgb(90, 70, 30)
} else {
Srgba8::rgb(50, 50, 60)
})
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, rsc: &mut Rsc| {
ctx.state.toggle_trace(rsc);
},
)
.label("Trace input and frames");
let trace = (
trace_rect,
wtext(if tracing { "Trace on" } else { "Trace off" })
.size(HEADER_TEXT)
.text_align(Align::CENTER),
)
.stack()
.pad(dp(8))
.add(rsc);
let row1 = (run, copy).span(Dir::RIGHT).add(rsc);
let row2 = (diagnostics, trace).span(Dir::RIGHT).add(rsc);
let buttons = (row1, row2).span(Dir::DOWN).add(rsc);
(rect(HEADER_SURFACE), buttons)
.stack()
.height(dp(2.0 * HEADER_ROW_HEIGHT_DP))
.pad(Padding::top(top_pad))
.add_strong(rsc)
.any()
}
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 (screen, tree) = ui::build_tree(rsc, ui::fixture::rows(&self.items));
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
fn show_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc);
self.report_display.edit(rsc).set(&report);
self.last_report = Some(report);
}
/// Turns the `iris::input`/`iris::frame` trace on or off, redraws the
/// switch that says so, and shows the pane that now reports it.
fn toggle_trace(&mut self, rsc: &mut Rsc) {
let on = !iris::diagnostics::trace_enabled();
iris::diagnostics::set_trace(on);
log::info!(
"iris diagnostics: input/frame trace {}",
if on { "on" } else { "off" }
);
let controls = bench_controls(rsc, self.last_top_pad);
(self.top_bar)(rsc).set(controls);
self.show_diagnostics(rsc);
}
/// The diagnostics report as text, with no side effect on what is on
/// screen -- shared by the `Diagnostics` button (which shows it) and
/// the keyboard-open capture (which only logs it), so the two can
/// never drift into reporting different things.
fn diagnostics_text(&self, rsc: &mut Rsc) -> String {
let font = rsc.ui.text.font_diagnostics();
let frame_report = match self.android_state().frame_report.report() {
Some(stats) => format!("{stats}"),
None => "no frames recorded yet".to_string(),
};
let renderer = match &self.android_state().renderer {
Some(renderer) => renderer.diagnostics_report(&font, &frame_report),
None => "iris diagnostics: no renderer yet (no surface)".to_string(),
};
// Insets must be visible without adb so a missing callback can be
// distinguished from a callback reporting zero IME height.
format!(
"{renderer}\n{}\n{}\n{}\n{}",
trace_line(
iris::diagnostics::trace_enabled(),
iris::diagnostics::trace_enabled()
),
self.android_state().insets_report(),
// Which server this build talks to, and what to do when the
// answer is "none" -- the bench itself opens a checked-in
// fixture and needs no server, so this pane is the only place
// an enrolment can be seen to have taken.
crate::android::enrollment::status_line(),
crate::android::app_log::diagnostics_line()
)
}
fn capture_keyboard_diagnostics(&mut self, rsc: &mut Rsc) {
let report = self.diagnostics_text(rsc);
log::info!("iris keyboard diagnostics:\n{report}");
}
fn copy_report(&mut self, rsc: &mut Rsc) {
let Some(platform) = &self.platform else {
log::info!("iris bench report: no platform handle, can't reach the clipboard");
return;
};
let report = match self.last_report.clone() {
Some(report) => report,
None => format!(
"no benchmark has run yet -- these are the diagnostics instead:\n\n{}",
self.diagnostics_text(rsc)
),
};
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");
}
}
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 ime_state = self.ime_state.clone();
let platform_hz = platform.as_ref().and_then(|p| p.refresh_rate_hz());
let cpu_start = process_cpu_ms();
// Read at the start as well as the end, because the switch is on
// screen while a run is going: a report that only asked afterwards
// would say "on" about a run whose first half has no trace in it
// -- the inferred answer presented as the measured one.
let trace_at_start = iris::diagnostics::trace_enabled();
let run_started_at = Instant::now();
rsc.spawn_task(async move |mut ctx| {
// The battery sampler runs for the whole run, once a second,
// the benchmark's established battery-sampling cadence
// -- 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(Mutex::new(Vec::<i32>::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 travel = run_fling_phase(&mut ctx, &redraw).await;
let (sent, total) = run_stream_phase(&mut ctx, &redraw, stream_tail).await;
run_type_phase(&mut ctx, &redraw, &platform).await;
let keyboard = run_keyboard_phase(&mut ctx, &platform, &ime_state).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(),
};
let total_seconds = run_started_at.elapsed().as_secs_f64();
ctx.update(move |state: &mut BenchClient, rsc| {
state.running = false;
let now = Instant::now();
let drawn_hz = state.android_state().frame_report.sustained_frame_hz();
let refresh_hz = match (drawn_hz, platform_hz) {
(Some(d), Some(p)) => d.max(p),
(Some(d), None) => d,
(None, Some(p)) => p,
(None, None) => 60.0,
};
let hz_line = match (drawn_hz, platform_hz) {
(Some(d), Some(p)) if d > p + 5.0 => format!(
" (sustained {d:.0}fps, so at least that; the display reported {p:.0}Hz)"
),
(Some(d), Some(_)) => format!(" (as the display reports it; drew {d:.0}fps)"),
(Some(d), None) => {
format!(" (sustained {d:.0}fps; the display would not say)")
}
(None, Some(_)) => {
" (as the display reports it; too few frames to measure)".to_string()
}
(None, None) => " (assumed -- neither measured nor reported)".to_string(),
};
let phase_lines: String = state
.android_state()
.frame_report
.phase_stats(now, refresh_hz)
.iter()
.map(|p| format!("{p}\n"))
.collect();
let per_phase = if phase_lines.is_empty() {
String::new()
} else {
format!("per phase:\n{phase_lines}\n")
};
let frames_block = match state.android_state().frame_report.report() {
Some(stats) => {
let (late, late_pct) =
state.android_state().frame_report.late_at_hz(refresh_hz);
format!(
"frames:\n {} frames over {:.1}s at {:.0}Hz{hz_line} ({:.1}ms \
budget)\n \
late: {late} ({late_pct:.1}%)\n total p50 {:.1}ms p90 {:.1}ms \
p99 {:.1}ms\n worst {:.1}ms\n build_p50 {:.1}ms acquire_p50 \
{:.1}ms submit_p50 {:.1}ms",
stats.total_frames,
total_seconds,
refresh_hz,
1000.0 / refresh_hz as f64,
stats.p50.as_secs_f64() * 1000.0,
stats.p90.as_secs_f64() * 1000.0,
stats.p99.as_secs_f64() * 1000.0,
stats.worst.as_secs_f64() * 1000.0,
stats.cpu_p50.as_secs_f64() * 1000.0,
stats.acquire_p50.as_secs_f64() * 1000.0,
stats.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
None => "frames:\n no frames recorded".to_string(),
};
let scroll_line = format!(
" scroll: {LEGACY_CYCLES} cycles ({} swipes, legacy tween), streamed \
{sent}/{total} fixture events",
LEGACY_CYCLES * 4
);
let fling_line = format!(
" fling: {FLING_COUNT} flings out + {FLING_COUNT} back at \
{FLING_VELOCITY_PX_S}px/s, travel {travel}"
);
let type_line = format!(
" type: {} characters inserted then deleted, one per {TYPE_CHAR_MS}ms",
TYPE_TEXT.chars().count()
);
let traced = trace_line(trace_at_start, iris::diagnostics::trace_enabled());
let report = format!(
"iris bench report\n{traced}\n{per_phase}{frames_block}\n\nbench:\n\
{fling_line}\n{scroll_line}\n{type_line}\n{keyboard}\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();
});
}
}
/// Runs `f` against the real `BenchClient`/`Rsc` on the main thread (the
/// same `ctx.update` every other mutation here goes through) and returns
/// its result to the caller's async task -- `ctx.update` alone has no way
/// to hand a value back, since the closure only actually runs once the
/// next frame callback drains `IrisViewPeer`'s task channel
/// (`drain_tasks`). **Must call `redraw.request_redraw()` itself, right
/// after enqueueing** -- `ctx.update` only ever pushes onto a channel;
/// nothing drains it until something schedules the frame callback that
/// calls `drain_tasks`, and a caller relying on some *earlier*,
/// already-in-flight `request_redraw()` to cover a *later* `ctx.update`
/// deadlocks the moment that earlier callback has already fired and
/// drained everything queued before this call existed. Cost a real hang
/// in this file's first version of the fling phase: every loop iteration
/// after the first sat forever with nothing scheduled to drain it.
/// Polls rather than assuming one `POLL_MS` sleep is enough, since a
/// slow device's frame callback can lag further than that.
async fn read_from_state<T, F>(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
f: F,
) -> T
where
T: Send + 'static,
F: FnOnce(&mut BenchClient, &mut Rsc) -> T + Send + 'static,
{
let (tx, rx) = mpsc::channel();
ctx.update(move |state: &mut BenchClient, rsc| {
let _ = tx.send(f(state, rsc));
});
redraw.request_redraw();
loop {
if let Ok(value) = rx.try_recv() {
return value;
}
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
}
}
async fn run_fling_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("fling");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
// Lets the next frame's `repair_anchor` resolve `jump_to_end`'s
// `anchor = None` into a real slot before `start` is read.
tokio::time::sleep(Duration::from_millis(POLL_MS * 2)).await;
let start = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let outward = read_anchor_position(ctx, redraw).await;
for _ in 0..FLING_COUNT {
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).fling(-FLING_VELOCITY_PX_S);
animate_scroll(screen.list, rsc);
}
});
redraw.request_redraw();
wait_for_fling_settle(ctx, redraw).await;
tokio::time::sleep(Duration::from_millis(FLING_PAUSE_MS)).await;
}
let end = read_anchor_position(ctx, redraw).await;
format!("start={start} outward={outward} end={end} ticked=frame-loop")
}
async fn read_anchor_position(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) -> String {
read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).anchor_position_display(),
None => "idx=none".to_string(),
})
.await
}
fn animate_scroll(scroll: iris::prelude::WeakWidget<iris::prelude::LazySpan>, rsc: &mut Rsc) {
let id = scroll.id();
rsc.ui_mut().animate(id);
}
async fn wait_for_fling_settle(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
) {
let cap = Duration::from_millis(FLING_SETTLE_CAP_MS);
let started = Instant::now();
while started.elapsed() < cap {
let still_scrolling = read_from_state(ctx, redraw, |state, rsc| match &state.screen {
Some(screen) => (screen.list)(rsc).is_scrolling(),
None => false,
})
.await;
if !still_scrolling {
return;
}
tokio::time::sleep(Duration::from_millis(POLL_MS)).await;
}
}
async fn run_stream_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
stream_tail: Vec<SeqEvent>,
) -> (usize, usize) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("stream");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
}
});
redraw.request_redraw();
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| {
let old_items = state.items.clone();
state.items = fold_event(&state.items, &event);
match state.screen.as_mut() {
Some(screen) => screen.apply(rsc, &old_items, &state.items),
None => state.rebuild_transcript(rsc),
}
});
redraw.request_redraw();
sent += 1;
tokio::time::sleep(Duration::from_millis(1000 / STREAM_EVENTS_PER_SEC)).await;
}
tokio::time::sleep(Duration::from_millis(300)).await;
(sent, total)
}
async fn run_type_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
redraw: &Arc<dyn RequestRedraw>,
platform: &Option<Arc<PlatformHandle>>,
) {
ctx.update(|state: &mut BenchClient, _rsc| {
state.android_state_mut().frame_report.mark_phase("type");
});
ctx.update(|state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
(screen.list)(rsc).jump_to_end();
state.set_focus(Some(screen.composer.field));
}
});
redraw.request_redraw();
if let Some(p) = platform {
p.show_ime();
}
tokio::time::sleep(Duration::from_millis(300)).await;
let mut typed = String::new();
for ch in TYPE_TEXT.chars() {
typed.push(ch);
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
while !typed.is_empty() {
typed.pop();
let text = typed.clone();
ctx.update(move |state: &mut BenchClient, rsc| {
if let Some(screen) = &state.screen {
screen.composer.field.edit(rsc).set(&text);
}
});
redraw.request_redraw();
tokio::time::sleep(Duration::from_millis(TYPE_CHAR_MS)).await;
}
}
async fn run_keyboard_phase(
ctx: &mut iris::task::TaskCtx<Rsc>,
platform: &Option<Arc<PlatformHandle>>,
ime_state: &Arc<Mutex<ImeState>>,
) -> String {
ctx.update(|state: &mut BenchClient, _rsc| {
state
.android_state_mut()
.frame_report
.mark_phase("keyboard");
});
let mut shown = 0;
let mut hidden = 0;
for _ in 0..KEYBOARD_CYCLES {
let before_shown = ime_state.lock().unwrap().shown_events;
if let Some(p) = platform {
p.show_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().shown_events > before_shown {
shown += 1;
}
let before_hidden = ime_state.lock().unwrap().hidden_events;
if let Some(p) = platform {
p.hide_ime();
}
tokio::time::sleep(Duration::from_millis(KEYBOARD_WAIT_MS)).await;
if ime_state.lock().unwrap().hidden_events > before_hidden {
hidden += 1;
}
}
if shown == 0 {
format!(" keyboard: could not be shown ({KEYBOARD_CYCLES} attempts, 0 confirmed visible)")
} else {
format!(
" keyboard: shown {shown}/{KEYBOARD_CYCLES}, hidden {hidden}/{KEYBOARD_CYCLES} \
(confirmed via on_insets_changed)"
)
}
}
#[cfg(test)]
mod tests {
use super::TYPE_TEXT;
#[test]
fn type_text_is_exactly_600_characters() {
assert_eq!(TYPE_TEXT.chars().count(), 600);
}
}
+179
View File
@@ -0,0 +1,179 @@
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<JObject<'e>> {
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<JObject<'e>> {
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()
}
pub fn battery_current_ua(&self) -> Option<i32> {
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(())
}
pub fn refresh_rate_hz(&self) -> Option<f32> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let display = env
.call_method(
self.view.as_obj(),
"getDisplay",
"()Landroid/view/Display;",
&[],
)
.ok()?
.l()
.ok()?;
if display.is_null() {
return None;
}
let rate = env
.call_method(&display, "getRefreshRate", "()F", &[])
.ok()?
.f()
.ok()?;
if rate > 0.0 { Some(rate) } else { None }
}
pub fn show_ime(&self) -> bool {
self.try_toggle_ime(true).unwrap_or(false)
}
pub fn hide_ime(&self) -> bool {
self.try_toggle_ime(false).unwrap_or(false)
}
fn try_toggle_ime(&self, show: bool) -> Option<bool> {
let mut guard = self.vm.attach_current_thread().ok()?;
let env: &mut JNIEnv = &mut guard;
let context = self.context(env)?;
let imm = self.system_service(env, &context, "input_method")?;
if show {
env.call_method(
&imm,
"showSoftInput",
"(Landroid/view/View;I)Z",
&[JValue::Object(self.view.as_obj()), JValue::Int(0)],
)
.ok()?
.z()
.ok()
} else {
let token = env
.call_method(
self.view.as_obj(),
"getWindowToken",
"()Landroid/os/IBinder;",
&[],
)
.ok()?
.l()
.ok()?;
env.call_method(
&imm,
"hideSoftInputFromWindow",
"(Landroid/os/IBinder;I)Z",
&[JValue::Object(&token), JValue::Int(0)],
)
.ok()?
.z()
.ok()
}
}
}
+160
View File
@@ -0,0 +1,160 @@
//! The JNI half of `DevLogProvider`: reading this process's own log ring
//! for a `ContentProvider` that Dev Updater queries.
use android_view::jni::JNIEnv;
use android_view::jni::objects::{JClass, JObject, JString};
use android_view::jni::sys::{jlong, jobjectArray};
use std::{path::Path, ptr, sync::OnceLock};
/// 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();
#[cfg(feature = "bench")]
pub fn authority() -> Option<&'static str> {
AUTHORITY.get().map(String::as_str)
}
/// 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,
) {
#[cfg(feature = "transcript-screen")]
if let Some(dir) = string_arg(&mut env, &files_dir) {
crate::android::app_log::set_crash_dir(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);
}
fn string_arg(env: &mut JNIEnv, value: &JString) -> Option<String> {
if value.is_null() {
return None;
}
env.get_string(value).ok().map(Into::into)
}
/// `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.
///
/// # 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())
}
/// 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))
}
#[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()
}
/// 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 = 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()
}
+87
View File
@@ -0,0 +1,87 @@
#[cfg(not(feature = "bench"))]
use crate::client::api::UreqTransport;
use crate::client::config::{EnrolledServer, EnrollmentStore};
use std::path::PathBuf;
use std::sync::OnceLock;
static FILES_DIR: OnceLock<PathBuf> = OnceLock::new();
pub fn set_files_dir(dir: PathBuf) {
if let Err(existing) = FILES_DIR.set(dir.clone()) {
assert_eq!(
existing, dir,
"the app's files directory was set twice with different paths"
);
}
}
fn store() -> Option<EnrollmentStore> {
FILES_DIR.get().map(EnrollmentStore::new)
}
pub enum Status {
Enrolled(EnrolledServer),
NotEnrolled,
/// The question could not be answered -- the activity never handed a
/// files directory over, or the file is there and unreadable. Kept
/// apart from `NotEnrolled` because the two want different actions
/// from whoever is looking.
Unknown(String),
}
pub fn status() -> Status {
let Some(store) = store() else {
return Status::Unknown("the activity never handed over a files directory".to_string());
};
match store.load() {
Ok(Some(server)) => Status::Enrolled(server),
Ok(None) => Status::NotEnrolled,
Err(error) => Status::Unknown(error.to_string()),
}
}
/// One line for the diagnostics pane. The three states read differently on
/// purpose: "not enrolled" says what to do about it, and "couldn't tell"
/// must not be mistaken for it.
#[cfg(feature = "bench")]
pub fn status_line() -> String {
match status() {
Status::Enrolled(server) => format!("enrolled: {}:{}", server.host, server.port),
Status::NotEnrolled => "not enrolled -- open the enrol link from Dev Updater".to_string(),
Status::Unknown(why) => format!("enrolment unreadable: {why}"),
}
}
pub fn apply_link(uri: &str) -> Result<EnrolledServer, String> {
let server = EnrolledServer::parse_link(uri)?;
let store = store().ok_or("the app has no files directory to save an enrollment in")?;
store
.save(&server)
.map_err(|error| format!("couldn't save the enrollment: {error}"))?;
Ok(server)
}
/// Gated to the same builds as `transcript_client`, its only caller: the
/// bench build opens a checked-in fixture and reaches no server, so
/// compiling this into it would be a warning about dead code that is
/// dead on purpose.
///
/// Every failure here is a sentence a screen can show, because there is
/// nowhere else for it to go: this app has no `logcat` on the phone it is
/// built for.
#[cfg(not(feature = "bench"))]
pub fn transport() -> Result<UreqTransport, String> {
let server = match status() {
Status::Enrolled(server) => server,
Status::NotEnrolled => {
return Err("Not enrolled yet -- open the enrol link from Dev Updater.".to_string());
}
Status::Unknown(why) => return Err(format!("Couldn't read the enrollment: {why}")),
};
let ca_pem = server.ca_pem.as_ref().ok_or(
"The enrollment link carried no CA, so there is nothing to pin. \
Enrol again with a link minted by this server.",
)?;
UreqTransport::new(server.base_url(), &server.token, ca_pem.as_bytes())
.map_err(|error| error.message)
}
+169
View File
@@ -0,0 +1,169 @@
use android_view::{
Context, View,
jni::{
JNIEnv, JavaVM,
objects::{JClass, JString},
sys::{JNI_VERSION_1_6, JavaVM as RawJavaVM, jint, jlong},
},
register_view_class,
};
#[cfg(not(feature = "transcript-screen"))]
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use log::LevelFilter;
use std::{
ffi::c_void,
path::{Path, PathBuf},
};
/// The app's own log ring and its upload -- only where `client-core` is
/// linked, which is every build that has a server to send to. The plain
/// tabs demo keeps `android_logger` alone, as it always had.
#[cfg(feature = "transcript-screen")]
mod app_log;
#[cfg(feature = "bench")]
mod bench_client;
#[cfg(feature = "bench")]
mod bench_jni;
mod devlog;
#[cfg(feature = "transcript-screen")]
mod enrollment;
#[cfg(all(feature = "transcript-screen", not(feature = "bench")))]
mod transcript_client;
/// The app's `View` subclass, matching the Java side's package --
/// `app/src/main/java/dev/iris/android/demo/IrisView.java`.
const VIEW_CLASS: &str = "dev/iris/android/demo/IrisView";
#[cfg(not(feature = "transcript-screen"))]
pub struct Client {
ui_state: AndroidUiState,
}
#[cfg(not(feature = "transcript-screen"))]
impl HasAndroidUiState for Client {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
#[cfg(not(feature = "transcript-screen"))]
impl AndroidAppState for Client {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
// `widgets.info` is the winit example's frame-debug readout, kept
// current from `DesktopAppState::window_event` -- android-view has
// no per-frame hook to drive the equivalent from here yet, so it
// is left at its built "" text rather than wired to nothing.
let _ = tabs_ui::build(rsc, &mut ui_state);
Self { ui_state }
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>) -> bool {
false
}
}
#[cfg(not(feature = "transcript-screen"))]
type ActiveClient = Client;
#[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>,
view: View<'local>,
context: Context<'local>,
) -> jlong {
iris::android::new_peer::<ActiveClient>(env, view, context)
}
/// # Safety
/// Interacting with JNI at load time is always unsafe at some level --
/// mirrors android-view's own demo, which carries the same comment.
#[unsafe(no_mangle)]
pub unsafe extern "system" fn JNI_OnLoad(vm: *mut RawJavaVM, _: *mut c_void) -> jint {
// The ring in front of `android_logger` where there is one (see
// `app_log`), and `android_logger` alone otherwise. Both install the
// same tag and level, so `logcat` cannot tell the two builds apart --
// the ring only adds a second reader.
#[cfg(feature = "transcript-screen")]
app_log::install(LevelFilter::Debug);
#[cfg(not(feature = "transcript-screen"))]
android_logger::init_once(
android_logger::Config::default()
.with_max_level(LevelFilter::Debug)
.with_tag("iris-android-app"),
);
let vm = unsafe { JavaVM::from_raw(vm) }.unwrap();
let mut env = vm.get_env().unwrap();
register_view_class(&mut env, VIEW_CLASS, new_view_peer);
iris::android::register_native_methods(&mut env, VIEW_CLASS);
JNI_VERSION_1_6
}
/// `MainActivity.nativeSetFilesDir` -- the app's private directory, handed
/// over before the view exists because that is where the enrollment is
/// read from and written to (`enrollment`'s module doc).
///
/// Exported by name rather than registered through `RegisterNatives`: the
/// view's methods are registered because `android-view` owns that class
/// and hands out one function pointer, whereas these two are this app's
/// own activity and the mangled name is the whole of what is needed.
///
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir(
mut env: JNIEnv,
_class: JClass,
dir: JString,
) {
let Some(dir) = jstring(&mut env, dir) else {
return;
};
#[cfg(feature = "transcript-screen")]
{
app_log::set_crash_dir(Path::new(&dir));
enrollment::set_files_dir(PathBuf::from(&dir));
}
log::debug!("iris app: files directory is {dir}");
}
/// # Safety
/// Called by the JVM with the arguments its `native` declaration names.
#[unsafe(no_mangle)]
pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeEnroll(
mut env: JNIEnv,
_class: JClass,
uri: JString,
) {
let Some(uri) = jstring(&mut env, uri) else {
return;
};
#[cfg(feature = "transcript-screen")]
match enrollment::apply_link(&uri) {
// Never the token: `wg-app-link`'s enroll module forbids logging
// it, and this line would otherwise be the one place it leaked.
Ok(server) => log::info!("iris app: enrolled with {}:{}", server.host, server.port),
Err(error) => log::warn!("iris app: that enrolment link was refused -- {error}"),
}
#[cfg(not(feature = "transcript-screen"))]
log::warn!("iris app: {uri} arrived, but this build has no server to enrol with");
}
fn jstring(env: &mut JNIEnv, value: JString) -> Option<String> {
if value.is_null() {
log::warn!("iris app: the activity passed a null string across JNI");
return None;
}
match env.get_string(&value) {
Ok(value) => Some(value.into()),
Err(error) => {
log::warn!("iris app: couldn't read a string from the activity -- {error}");
None
}
}
}
+299
View File
@@ -0,0 +1,299 @@
use crate::client::api::{ApiClient, UreqTransport};
use crate::client::event_stream::{StreamItem, follow_session_events};
use crate::client::transcript_fold::{
TranscriptItem, fold_event, fold_page, group_tool_runs, raw_seq,
};
use crate::ui::{self, TranscriptScreen};
use event_model::SeqEvent;
use iris::android::{AndroidAppState, AndroidRsc, AndroidUiState, HasAndroidUiState};
use iris::prelude::*;
use std::{
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
thread,
};
pub struct TranscriptClient {
ui_state: AndroidUiState,
/// The screen's own content -- everything under the fixed
/// [`frame_report_controls`] bar, which is built once (`new`, below)
/// and never touched by `show_message`/`rebuild_transcript`'s own
/// `set` calls the way `desktop-app`'s `transcript_ptr` isn't touched
/// by rebuilding the session list beside it.
content: WeakWidget<WidgetPtr>,
screen: Option<TranscriptScreen>,
items: Vec<TranscriptItem>,
session_id: Option<String>,
generation: Arc<AtomicU64>,
}
impl HasAndroidUiState for TranscriptClient {
fn android_state(&self) -> &AndroidUiState {
&self.ui_state
}
fn android_state_mut(&mut self) -> &mut AndroidUiState {
&mut self.ui_state
}
}
fn build_transport() -> Result<UreqTransport, String> {
crate::android::enrollment::transport()
}
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string())
.color(PaintId::WHITE)
.wrap(true)
.pad(16)
.add_strong(rsc)
.any()
}
fn frame_report_controls(rsc: &mut AndroidRsc<TranscriptClient>) -> WeakWidget {
type Rsc = AndroidRsc<TranscriptClient>;
let report_rect = rect(Srgba8::rgb(50, 50, 60))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| match ctx
.state
.android_state()
.frame_report
.report()
{
Some(stats) => log::info!("iris frame report: {stats}"),
None => log::info!(
"iris frame report: no frames recorded -- scroll first, then press this"
),
},
)
.label("Frame report");
let report = (
report_rect,
wtext("Frame report").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.add(rsc);
let reset_rect = rect(Srgba8::rgb(70, 40, 40))
.on(
CursorSense::click(),
|ctx: EventIdCtx<'_, Rsc, _, _>, _rsc: &mut Rsc| {
ctx.state.android_state_mut().frame_report.reset();
log::info!("iris frame report: reset");
},
)
.label("Reset frame report");
let reset = (
reset_rect,
wtext("Reset").size(18).text_align(Align::CENTER),
)
.stack()
.pad(8)
.add(rsc);
(report, reset).span(Dir::RIGHT).height(56).add(rsc)
}
impl AndroidAppState for TranscriptClient {
fn new(mut ui_state: AndroidUiState, rsc: &mut AndroidRsc<Self>) -> Self {
let content = WidgetPtr::new().add(rsc);
let loading = placeholder(rsc, "Loading sessions...");
content(rsc).set(loading);
let tree = (frame_report_controls(rsc), content.height(rest(1)))
.span(Dir::DOWN)
.add_strong(rsc)
.any();
ui_state.set_root(rsc, tree);
let mut client = Self {
ui_state,
content,
screen: None,
items: Vec::new(),
session_id: None,
generation: Arc::new(AtomicU64::new(0)),
};
client.spawn_fetch_sessions(rsc);
client
}
fn back_pressed(&mut self, _rsc: &mut AndroidRsc<Self>) -> bool {
// No screen stack of its own -- same "let the activity finish"
// answer `iris-android-app`'s tabs `Client` already gives.
false
}
}
impl TranscriptClient {
fn show_message(&mut self, rsc: &mut AndroidRsc<Self>, message: &str) {
let widget = placeholder(rsc, message);
(self.content)(rsc).set(widget);
self.screen = None;
}
fn spawn_fetch_sessions(&mut self, rsc: &mut AndroidRsc<Self>) {
let redraw = rsc.tasks.redraw_handle();
let my_generation = self.generation.load(Ordering::SeqCst);
let generation = self.generation.clone();
rsc.spawn_task(async move |mut ctx| {
let outcome = match build_transport() {
Ok(transport) => ApiClient::new(transport)
.fetch_sessions()
.map_err(|e| e.to_string()),
Err(e) => Err(format!("couldn't set up TLS: {e}")),
};
ctx.update(move |state: &mut TranscriptClient, rsc| {
if generation.load(Ordering::SeqCst) != my_generation {
return;
}
match outcome {
Ok(sessions) => match sessions.into_iter().next() {
Some(session) => state.select_session(rsc, session.id),
None => state.show_message(rsc, "No sessions on the sandbox server."),
},
Err(message) => {
state.show_message(rsc, &format!("Couldn't list sessions: {message}"))
}
}
});
redraw.request_redraw();
});
}
fn select_session(&mut self, rsc: &mut AndroidRsc<Self>, session_id: String) {
let my_generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1;
self.items.clear();
self.session_id = Some(session_id.clone());
self.show_message(rsc, "Loading transcript...");
let redraw = rsc.tasks.redraw_handle();
let live_generation = self.generation.clone();
rsc.spawn_task(async move |mut ctx| {
let transports =
build_transport().and_then(|rest| build_transport().map(|stream| (rest, stream)));
let (rest, stream_transport) = match transports {
Ok(pair) => pair,
Err(e) => {
let message = format!("couldn't set up TLS: {e}");
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) == my_generation {
state.show_message(rsc, &message);
}
});
redraw.request_redraw();
return;
}
};
let api = ApiClient::new(rest);
let page: Result<Vec<serde_json::Value>, String> = api
.fetch_transcript_page(&session_id, None, 200, true)
.map_err(|e| e.to_string());
let after = page
.as_ref()
.ok()
.and_then(|values| values.last())
.and_then(raw_seq)
.unwrap_or(0);
let result = page.and_then(|values| fold_page(&values));
{
let live_generation = live_generation.clone();
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
match result {
Ok(items) => {
state.items = items;
state.rebuild_transcript(rsc);
}
Err(message) => {
state.show_message(rsc, &format!("Couldn't load transcript: {message}"))
}
}
});
}
redraw.request_redraw();
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
let _ =
follow_session_events(
&stream_transport,
&session_id,
after,
move |item| match item {
StreamItem::Open | StreamItem::Reset => {
live_generation.load(Ordering::SeqCst) == my_generation
}
StreamItem::Event { event, .. } => {
if live_generation.load(Ordering::SeqCst) != my_generation {
return false;
}
let live_generation = live_generation.clone();
ctx.update(move |state: &mut TranscriptClient, rsc| {
if live_generation.load(Ordering::SeqCst) != my_generation {
return;
}
state.apply_event(rsc, &event);
});
redraw.request_redraw();
true
}
},
);
});
}
fn rebuild_transcript(&mut self, rsc: &mut AndroidRsc<Self>) {
let in_progress = self
.screen
.as_ref()
.map(|screen| screen.composer.field.edit(rsc).text.text().to_string())
.filter(|t| !t.is_empty());
let rows = group_tool_runs(&self.items);
let (screen, tree) = ui::build_tree(rsc, rows);
if let Some(text) = in_progress {
screen.composer.field.edit(rsc).set(&text);
}
if let Some(session_id) = self.session_id.clone() {
let field = screen.composer.field;
rsc.register_event(field, Submit, move |ctx, rsc| {
let text = field.edit(rsc).take();
let text = text.trim().to_string();
if !text.is_empty() {
ctx.state.send_message(session_id.clone(), text);
}
});
}
(self.content)(rsc).set(tree);
self.screen = Some(screen);
}
fn apply_event(&mut self, rsc: &mut AndroidRsc<Self>, event: &SeqEvent) {
let old_items = self.items.clone();
self.items = fold_event(&self.items, event);
match self.screen.as_mut() {
Some(screen) => screen.apply(rsc, &old_items, &self.items),
None => self.rebuild_transcript(rsc),
}
}
fn send_message(&mut self, session_id: String, text: String) {
thread::spawn(move || {
if let Ok(transport) = build_transport() {
let api = ApiClient::new(transport);
let _ = api.send_message(&session_id, &text, &[]);
}
});
}
}