use crate::prelude::*; use crate::task::RequestRedraw; use accesskit_android::Adapter as AccessAdapter; use android_view::{ AccessibilityNodeInfo, AccessibilityNodeProvider, Bundle, CallbackCtx, Context, InputConnection, KeyEvent, MotionEvent, Rect, View, ViewPeer, jni::{ JNIEnv, JavaVM, objects::{GlobalRef, JValue}, sys::jint, }, ndk::event::{Axis, Keycode, MotionAction}, }; // `marker::Sized` explicitly: `crate::prelude::*` below also brings in the // `Sized` *widget* (`widget::position::sized::Sized`), and an unqualified // glob import shadows the language prelude -- `default/mod.rs` has the same // explicit import for the same reason. use std::{ cell::RefCell, marker::{PhantomData, Sized}, rc::Rc, sync::Arc, time::Instant, }; use super::{ access::{AndroidAccessSource, NullActionHandler, raise_if_enabled}, insets::{Insets, Shared}, render::{AndroidRedrawHandle, AndroidRenderer}, }; /// The android-view analogue of `default::DefaultUiState`. `renderer` is an /// `Option` because a `SurfaceView`'s surface does not outlive backgrounding /// the way a winit `Window` does -- `surfaceDestroyed`/`surfaceCreated` can /// happen any number of times over the life of one `IrisViewPeer`. /// How many frames after each `surface_changed` `render()` logs a full /// diagnostic line for -- see the log site's own comment. const DIAGNOSTIC_FRAMES: u64 = 10; pub struct AndroidUiState { pub root: Option, pub renderer: Option, pub focus: Option>, pub cursor: CursorState, pub last_click: Instant, /// The IME preedit's previous length, in `char`s -- the same /// re-send-the-whole-composition bookkeeping `default::DefaultUiState` /// keeps for winit's `Ime::Preedit`, since android-view's /// `setComposingText` has the identical shape (see `android/ime.rs`). pub compose_len: usize, /// Set by `attr::FocusHost::focus_gained` when a `TextEdit` is focused; /// consumed by the touch handler after the sensor pass finishes, since /// showing the keyboard is a JNI call and `focus_gained` runs deep /// inside the platform-agnostic sensor dispatch with no `CallbackCtx` /// in reach. pub pending_show_keyboard: bool, /// A URL a tapped link asked the platform to open, for the same /// reason `pending_show_keyboard` is a flag rather than a call -- /// see `android/platform.rs`. pub pending_open_url: Option, /// Window insets, filled in from outside the normal `ViewPeer` callback /// path -- see `android/insets.rs` for why they need a registry of /// their own. shared: Rc>, /// I4 (RUST.md): pushed from `IrisViewPeer::render` and consulted by /// the `AccessibilityNodeProvider` impl below; see `android/access.rs` /// for the abort mitigation every `raise` on it goes through. pub access_adapter: AccessAdapter, /// The AccessKit tree itself -- see `iris_core::AccessTree`'s doc /// comment. pub access: AccessTree, /// iris's own frame-time report (RUST.md's I5 box, "Measurements /// taken" (b)) -- `render()` below records into it once per frame, /// because `dumpsys gfxinfo` cannot see a `SurfaceView`'s own /// GPU-drawn frames at all. See `iris_core::FrameReport`'s own doc. pub frame_report: FrameReport, /// `DisplayMetrics.density` (`new_peer`'s doc comment): physical pixels /// per dp on this device, read once at view construction and carried /// on `UiRenderState::density` (`render.set_density`, `new_peer`) from /// then on -- every `Len::dp` in the widget tree resolves against it at /// layout time (`Len::dp`'s field doc, IRIS_TODO.md's /// "density-independent length unit" item, 2026-09-06). /// /// **Everything else in this module is physical pixels, matching the /// real wgpu surface/swapchain resolution** -- window size, touch /// coordinates, insets. That is a correction from an earlier version /// of this comment, which had `window_size`/`surface_changed`'s /// `UiRenderState::resize` call divide by `content_scale` into a /// *logical* coordinate space instead, as a global stopgap for /// RUST.md's P0 box's phone report ("text is far too small"). That /// stopgap fixed the size but not the *sharpness*: dividing to logical /// units meant a `16.0`-sized glyph rasterised at 16 physical px and /// then implicitly upscaled ~3x by the NDC mapping onto the real /// physical framebuffer -- the exact "blurry ... glyphs drawn at /// logical size and stretched by the scale" Iris reported next. /// Resolving `dp` at layout time replaces it: a widget author writes /// `dp(16)` for a size that should look the same physical size on any /// density, and everything downstream (layout, hit-testing, the window /// uniform, and the font size handed to the text shaper) works in the /// display's own physical pixels throughout, so nothing is /// rasterised at one resolution and displayed at another. pub content_scale: f32, /// The last insets `render()` saw -- compared each frame so /// `AndroidAppState::on_insets_changed` fires only when they actually /// change (once at startup for the status bar, again if the device /// rotates), not every frame. last_insets: Insets, } impl AndroidUiState { fn new(shared: Rc>, content_scale: f32) -> Self { Self { root: None, renderer: None, focus: None, cursor: Default::default(), last_click: Instant::now(), compose_len: 0, pending_show_keyboard: false, pending_open_url: None, shared, access_adapter: Default::default(), access: AccessTree::new(), frame_report: FrameReport::new(), content_scale, last_insets: Insets::default(), } } pub fn insets(&self) -> Insets { self.shared.borrow().insets } /// The insets state as one line for a diagnostics pane, including how /// many times the platform has delivered any -- see /// `insets::Shared::updates` for why the count is the load-bearing /// part. `dispatches=0` says the listener has never run and the /// numbers beside it are defaults rather than measurements, which is /// the distinction a screenshot otherwise cannot make (UI_RULES.md, /// "design the unknown state first"). pub fn insets_report(&self) -> String { let shared = self.shared.borrow(); let i = shared.insets; if shared.updates == 0 { return "insets: dispatches=0 -- the platform has never called \ onApplyWindowInsets, so nothing below was measured" .to_string(); } format!( "insets: dispatches={} left={} top={} right={} bottom={} ime_bottom={} \ ime_visible={}", shared.updates, i.left, i.top, i.right, i.bottom, i.ime_bottom, i.ime_visible, ) } } impl HasRoot for AndroidUiState { fn set_root(&mut self, root: StrongWidget) { self.root = Some(root); } } pub trait HasAndroidUiState: Sized + 'static { fn android_state(&self) -> &AndroidUiState; fn android_state_mut(&mut self) -> &mut AndroidUiState; } pub trait AndroidAppState: HasAndroidUiState { fn new(ui_state: AndroidUiState, rsc: &mut AndroidRsc) -> Self; /// The system back gesture/button. `true` means handled -- nothing /// further happens; `false` lets the activity finish as it would with /// no view at all. The default declines, since most screens have /// nothing to intercept it for. #[allow(unused_variables)] fn back_pressed(&mut self, rsc: &mut AndroidRsc, render: &mut UiRenderState) -> bool { false } /// Called once, right after `new`, with a fresh `JavaVM` handle and a /// global reference to this app's own `View` -- for a caller that /// needs to call into Java itself beyond what a [`RequestRedraw`] /// handle already covers (P0's bench build calling /// `BatteryManager`/`ClipboardManager` through the view's `Context`, /// docs/RUST.md). Not folded into `new` itself: most implementors need /// nothing here, and `new`'s job is building the widget tree, not /// holding a platform handle -- the default does nothing. `vm`/`view` /// are independent handles from the ones `new_peer` keeps for its own /// `RequestRedraw` (a fresh `get_java_vm`/`new_global_ref` each), so /// storing them has no effect on that mechanism. #[allow(unused_variables)] fn platform_ready(&mut self, rsc: &mut AndroidRsc, vm: JavaVM, view: GlobalRef) {} /// Called from `render()` whenever `AndroidUiState::insets()` differs /// from what it was last frame -- once at startup for the status bar /// (RUST.md's P0 box: "the status-bar inset is not applied" reported /// the two top buttons sitting under it, because nothing read `.top` /// at all), and again on a rotation or the keyboard opening/closing. /// `insets` is in the same physical-pixel units everything else in the /// tree now uses (`AndroidUiState::content_scale`'s field comment), so /// a widget can add it to a layout size directly -- `dp(...) + /// abs(insets.top)` if the widget wants a density-independent size /// plus the system bar's own (already-physical) height. The default /// does nothing -- most screens have no chrome that sits under a /// system bar. #[allow(unused_variables)] fn on_insets_changed(&mut self, rsc: &mut AndroidRsc, insets: WindowInsets) {} } /// `insets::Insets` as `f32`, for the widget-facing callback above -- a /// distinct type from `insets::Insets` so a caller of `on_insets_changed` /// is not coupled to that module's own (`i32`, JNI-shaped) representation. /// Both are physical pixels; this used to divide by `content_scale` into a /// separate *logical* unit (hence the old name, `LogicalInsets`), back when /// the rest of layout was logical too -- see `AndroidUiState::content_scale`'s /// field comment for why that stopgap is gone. #[derive(Clone, Copy, Default, Debug, PartialEq)] pub struct WindowInsets { pub left: f32, pub top: f32, pub right: f32, pub bottom: f32, /// How much of the window the keyboard covers, in physical pixels -- /// what a layout pads by. See `insets::Insets::ime_visible` for why /// "is the keyboard up" is a separate field rather than this one /// compared against zero. pub ime_bottom: f32, pub ime_visible: bool, } impl WindowInsets { fn from_physical(insets: Insets) -> Self { Self { left: insets.left as f32, top: insets.top as f32, right: insets.right as f32, bottom: insets.bottom as f32, ime_bottom: insets.ime_bottom as f32, ime_visible: insets.ime_visible, } } } /// The android-view analogue of `default::DefaultRsc` -- identical in /// substance, since none of `UiRsc`/`HasEvents`/`HasTasks`/`HasWidgetState` /// mention winit. Kept as a separate type rather than shared code because /// the two backends' `ViewPeer`/`ApplicationHandler` entry points hold /// their harness state differently (see RUST.md's I2). pub struct AndroidRsc { pub ui: UiData, pub events: EventManager, pub tasks: Tasks, pub state: WidgetState, _state: PhantomData, } impl AndroidRsc { pub fn create_state(&mut self, id: impl IdLike, data: T) -> WeakState { self.state.add(id.id(), data) } } impl UiRsc for AndroidRsc { fn ui(&self) -> &UiData { &self.ui } fn ui_mut(&mut self) -> &mut UiData { &mut self.ui } fn on_draw(&mut self, active: &ActiveData) { self.events.draw(active); } fn on_undraw(&mut self, active: &ActiveData) { self.events.undraw(active); } fn on_remove(&mut self, id: WidgetId) { self.events.remove(id); self.state.remove(id); } } impl HasState for AndroidRsc { type State = State; } impl HasEvents for AndroidRsc { fn events(&self) -> &EventManager { &self.events } fn events_mut(&mut self) -> &mut EventManager { &mut self.events } } impl HasTasks for AndroidRsc { fn tasks_mut(&mut self) -> &mut Tasks { &mut self.tasks } } impl HasWidgetState for AndroidRsc { fn widget_state(&self) -> &WidgetState { &self.state } fn widget_state_mut(&mut self) -> &mut WidgetState { &mut self.state } } /// The `ViewPeer` android-view dispatches every callback to. One per /// `RustView` instance; `new_peer` (below) builds it and hands the id to /// Java the same way android-view's own demo does. pub struct IrisViewPeer { pub(super) rsc: AndroidRsc, pub(super) render: UiRenderState, pub(super) state: State, task_recv: TaskMsgReceiver>, /// The one ruler this view dates everything on: touch samples in /// `on_touch_event` and the `Choreographer` frame time in `do_frame`. /// Anchored by whichever of the two arrives first and never /// re-anchored after, which is what lets a fling be advanced on the /// same clock the gesture that launched it was measured on. Its path /// out is the peer's own drop: it holds nothing but three numbers and /// is meaningless to any other view. device_clock: Option, } impl>> std::ops::Index for AndroidRsc { type Output = I::Output; fn index(&self, index: I) -> &Self::Output { index.get(self) } } impl>> std::ops::IndexMut for AndroidRsc { fn index_mut(&mut self, index: I) -> &mut Self::Output { index.get_mut(self) } } impl IrisViewPeer { fn drain_tasks(&mut self) { while let Ok(update) = self.task_recv.try_recv() { update(&mut self.state, &mut self.rsc); } } /// One pointer sample through the sensors, plus the platform calls a /// handler can only ask for by raising a flag. Split out of /// [`Self::after_input`] because a batched `MotionEvent` carries /// several samples that all belong to the same *frame* /// (`on_touch_event`): each one is a real input frame the widgets must /// see, but only the last one ends the frame and asks for a redraw. fn run_input_frame(&mut self, ctx: &mut CallbackCtx) { let window_size = self.window_size(); let ui_state = self.state.android_state_mut(); let cursor = ui_state.cursor.clone(); let old_focus = ui_state.focus; self.render .run_sensors(&mut self.rsc, &mut self.state, cursor, window_size); let ui_state = self.state.android_state_mut(); if old_focus != ui_state.focus && let Some(old) = old_focus { old.edit(&mut self.rsc).deselect(); } if std::mem::take(&mut ui_state.pending_show_keyboard) { show_soft_input(&mut ctx.env, &ctx.view); } if let Some(url) = ui_state.pending_open_url.take() { super::platform::open_url(&mut ctx.env, &ctx.view, &url); } } /// Common tail for every callback that might have changed the cursor, /// the text focus, or the widget tree: run the sensors that touch /// input feeds, then ask for a frame if the result needs drawing. /// Mirrors `default::DefaultApp::window_event`'s tail, split across /// android-view's several entry points instead of winit's one. pub(super) fn after_input(&mut self, ctx: &mut CallbackCtx) { self.run_input_frame(ctx); // RUST.md's P0 box, "doesn't enter it until I hit space, and also // doesn't move cursor forward": Gboard needs `updateSelection` // after every edit to keep its own model of the field in sync, or // it holds keystrokes back rather than trusting a screen it // believes is stale. See `update_ime_selection`'s own doc. self.update_ime_selection(ctx); let ui_state = self.state.android_state_mut(); ui_state.cursor.end_frame(); if self.render.needs_redraw(&ui_state.root, self.rsc.widgets()) { ctx.view.post_frame_callback(&mut ctx.env); } } /// The view's one clock, anchoring it on `nanos` if nothing has yet /// -- see the `device_clock` field. Either source may be the first to /// arrive: a frame callback fires before any touch on an app that /// animates at startup, and a touch arrives first on one that does /// not. /// /// `oldest` is the earliest sample the anchoring event carries, which /// matters only when this is the call that anchors -- see /// `DeviceClock::anchored`. A frame time carries no batch, so it /// passes its own time for both. fn device_clock(&mut self, event_time: i64, oldest: i64) -> DeviceClock { *self .device_clock .get_or_insert_with(|| DeviceClock::anchored(Instant::now(), event_time, oldest)) } fn window_size(&self) -> Vec2 { let ui_state = self.state.android_state(); match &ui_state.renderer { Some(r) => r.size(), None => Vec2::ZERO, } } /// The `log::debug!` calls here are a live diagnostic for a still-open /// finding (RUST.md's I2): layout runs and reports the right pixel /// region for the root (confirmed via `window_region`, logged below), /// and the clear colour reaches the screen (confirmed by swapping it to /// magenta and screenshotting), but no primitive ever appears on top of /// it -- on both the Vulkan/SwiftShader and GLES/virgl backends. Leave /// these in until that is root-caused; removing them loses the exact /// evidence a `logcat` capture needs to reproduce the state. Gated on /// `iris::diagnostics::trace_enabled` since 2026-09-07 (docs/RUST.md's /// review, D1): unconditional, they were two `debug!` lines every /// rendered frame, and `client_core::log_ring`'s `RingLogger` records /// every level the app's already-`Debug` install lets through /// regardless of target, so they filled the whole ring in under ten /// seconds at 120Hz and left `Copy report` nothing else to show. fn render(&mut self, ctx: &mut CallbackCtx, now: Instant) { if self.state.android_state().renderer.is_none() { return; } // See `AndroidAppState::on_insets_changed`'s doc comment: fires // exactly when insets actually differ from last frame, not every // frame -- most frames this is one `Insets` equality check against // a `Copy` struct. Done before `ui_state` is bound below, since // `on_insets_changed` needs `&mut self.state`/`&mut self.rsc` both. let ui_state = self.state.android_state(); let current_insets = ui_state.insets(); if current_insets != ui_state.last_insets { let physical = WindowInsets::from_physical(current_insets); // One line per real insets change. Iris's phone is the only // place several of these bugs reproduce and `adb logcat` is // the only instrument there (this-machine-android: system // tracing is broken on that device), so the numbers a layout // is actually fed have to reach the log -- "the composer // floats at launch" is unanswerable from a screenshot alone. log::info!( "iris insets: left={} top={} right={} bottom={} ime_bottom={} \ ime_visible={} window={:?}", physical.left, physical.top, physical.right, physical.bottom, physical.ime_bottom, physical.ime_visible, self.window_size(), ); self.state.android_state_mut().last_insets = current_insets; self.state.on_insets_changed(&mut self.rsc, physical); } // Gated the same way `iris::frame`'s own line is (docs/RUST.md's // "Phone logging" review, D1): a bare `log::debug!` reaches // `client_core::log_ring`'s ring regardless of level, since // `RingLogger::enabled` is unconditionally `true` and the app // installs at `LevelFilter::Debug` -- two of these a rendered // frame filled the 2000-line ring in under ten seconds at 120Hz, // leaving `Copy report` nothing but frame spam. See // `iris::diagnostics`'s module doc. if crate::diagnostics::trace_enabled() { let ui_state = self.state.android_state(); log::debug!( target: "iris::frame", "render(): root={:?} widgets={} active={} root_px={:?} out_size={:?}", ui_state.root.is_some(), self.rsc.widgets().len(), self.render.active_widgets(), ui_state .root .as_ref() .and_then(|r| self.render.window_region(r, &self.rsc)), self.window_size(), ); } // iris's own frame-time report (RUST.md's I5 box, "Measurements // taken" (b)): started here, at the same point a redraw request // fires, and stopped after `renderer.draw()`'s `queue.submit` + // `present()` -- the span Compose's render report and `gfxinfo` // both count. See `iris_core::FrameReport`'s own doc for exactly // what this does and does not measure. let frame_start = Instant::now(); // Anything moving on its own -- today a `LazySpan` coasting // through a fling -- is advanced here, before the draw. **On // `now`, not on `frame_start`**: `now` is the vsync the // `Choreographer` handed this callback, which is evenly spaced, // while `frame_start` is whenever the callback actually got to // run. The frames are *presented* on the even cadence either way, // so sampling the animation on the uneven one moves the content by // an uneven distance per frame -- a fling that shimmers with no // frame late enough to show up in a report. See // `UiData::tick_animations` and `sense::DeviceClock`; // `default/mod.rs`'s `RedrawRequested` arm is the winit half. let animating = self.rsc.ui.tick_animations(now); // **Asked for before the work, not after it.** A frame callback is // one-shot, so an animation that wants another frame has to say so // every frame -- and `Choreographer.postFrameCallback` schedules // for the next vsync *after the call*. Asking at the end of this // function meant any frame whose work ran past the vsync boundary // (the swapchain acquire below alone can sit most of a frame) // registered too late for the next one and got the one after -- // so one frame over budget silently cost a second frame as well. // Unlike `after_input`, which only has to ask when input dirtied // something. if animating { ctx.view.post_frame_callback(&mut ctx.env); } let ui_state = self.state.android_state_mut(); self.render.update(&ui_state.root, &mut self.rsc); let ui_state = self.state.android_state_mut(); let Some(renderer) = &mut ui_state.renderer else { return; }; let frame_diagnostics = renderer.update(&mut self.rsc.ui, &mut self.render); // First `DIAGNOSTIC_FRAMES` frames after each `surface_changed` // only -- RUST.md's P0 box, "the first input frame" investigation: // the glyph-wipe Iris reported happens on the first tap or scroll // after a fresh surface, so a report from that window is what // would show whether an atlas grow, a masks/move_offsets resize, or // a fresh wgpu error coincided with it. `frame_count()` was just // incremented inside `update()`, so `<=` counts frame 1 through // `DIAGNOSTIC_FRAMES` inclusive. if renderer.frame_count() <= DIAGNOSTIC_FRAMES { log::info!( "iris frame diagnostics: frame={} masks_resized={} moves_resized={} \ atlas_pages_grown_prev={} image_bind_group_creates_prev={} wgpu_errors={}", renderer.frame_count(), frame_diagnostics.masks_resized, frame_diagnostics.moves_resized, frame_diagnostics.atlas_pages_grown_prev, frame_diagnostics.image_bind_group_creates_prev, renderer.wgpu_errors.snapshot().len(), ); } let mut parts = renderer.draw(); parts.total = frame_start.elapsed(); // Dated on `now` -- the vsync this frame was for -- so the gap // between consecutive frames is the display's own cadence and // `PhaseStats::missed` counts vsyncs nothing was drawn for. self.state .android_state_mut() .frame_report .record(now, parts, animating); crate::diagnostics::log_frame(&self.render, now, parts, animating); if crate::diagnostics::trace_enabled() { let ui_state = self.state.android_state(); log::debug!( target: "iris::frame", "render(): after update active={} root_px={:?}", self.render.active_widgets(), ui_state .root .as_ref() .and_then(|r| self.render.window_region(r, &self.rsc)), ); } // I4 (RUST.md): only produces a `TreeUpdate` -- and so only queues // anything to raise -- when the named set actually changed this // frame; see `AccessTree`'s doc comment. Deferred rather than // raised inline so it runs after this callback releases whatever // it's holding, matching android-view's own demo and `raise`'s own // contract. let ui_state = self.state.android_state_mut(); if let Some(tree_update) = ui_state .access .update(self.rsc.widgets(), &self.render, &self.rsc) { let ui_state = self.state.android_state_mut(); if let Some(events) = ui_state.access_adapter.update_if_active(|| tree_update) { ctx.push_dynamic_deferred_callback(move |env, view| { raise_if_enabled(env, view, events); }); } } } } fn show_soft_input<'local>(env: &mut JNIEnv<'local>, view: &View<'local>) { let imm = view.input_method_manager(env); imm.show_soft_input(env, view, 0); } /// Replaces the activity's content with a plain, selectable, scrollable /// text view holding `report` -- the on-screen half of `surface_changed`'s /// renderer-failure path (UI_RULES.md: "a failure is reported where it /// happened, and says what to do next," here "copy this and send it"). /// Goes through an ordinary instance method on the Java side /// (`IrisView.showRendererError`) rather than a new `native` method: this /// call is Rust reaching *into* Java, the opposite direction from every /// `native fn` android-view/`IrisView` declare, and an ordinary virtual /// call resolves against `ctx.view`'s real runtime class (`IrisView`) the /// same way any other JNI method call here does. Silently does nothing on /// any JNI failure -- there is no more-fallback screen to fall back to, /// and the `log::error!` in `surface_changed` already reached logcat /// first. fn show_renderer_error<'local>(env: &mut JNIEnv<'local>, view: &View<'local>, report: &str) { let Ok(message) = env.new_string(report) else { return; }; let _ = env.call_method( &view.0, "showRendererError", "(Ljava/lang/String;)V", &[JValue::Object(message.as_ref())], ); } impl ViewPeer for IrisViewPeer { fn on_key_down<'local>( &mut self, ctx: &mut CallbackCtx<'local>, key_code: Keycode, event: &KeyEvent<'local>, ) -> bool { self.drain_tasks(); // With no `OnBackPressedCallback` registered on the Java side, the // system still delivers the back gesture as a synthetic // `KEYCODE_BACK` through this same path -- the legacy behaviour // every view-based app gets by default, and enough for "the back // gesture as an event" without a second JNI registry. See // `android/insets.rs`'s doc comment for why insets could not take // the same shortcut. if key_code == Keycode::Back { let handled = self.state.back_pressed(&mut self.rsc, &mut self.render); if handled { self.after_input(ctx); } return handled; } let handled = super::input::on_key( &mut self.rsc, &mut self.state, &mut ctx.env, key_code, event, ); if handled { self.after_input(ctx); } handled } fn on_touch_event<'local>( &mut self, ctx: &mut CallbackCtx<'local>, event: &MotionEvent<'local>, ) -> bool { self.drain_tasks(); let action = event.action_masked(&mut ctx.env); // Device (physical) pixels, same space layout now uses throughout // -- see `AndroidUiState::content_scale`'s field comment. let x = event.x(&mut ctx.env); let y = event.y(&mut ctx.env); // The event's own clock, converted through the view's one anchor // -- taken on whichever of a touch or a frame callback came first. // Android reports sample times in the `SystemClock.uptimeMillis()` // base, which is the same `CLOCK_MONOTONIC` an `Instant` reads, so // a single `(Instant, nanos)` pair converts every later sample // exactly. Anchoring **once** rather than per event is what keeps // the times ordered, and anchoring on the first event's *oldest* // sample rather than on its own time is what keeps that event's // batch from collapsing onto one instant -- `sense::DeviceClock`'s // doc has both, and owns the arithmetic so it can be unit-tested // off a device (`sense_tests.rs`). See `CursorState::time`. let event_time = event.event_time_nanos(&mut ctx.env); let history = event.history_size(&mut ctx.env); let mut clock = match self.device_clock { Some(clock) => clock, // Only the call that anchors needs the batch's oldest sample, // so the JNI read for it stays off the per-event path. None => { let oldest = if history > 0 { event.historical_event_time_nanos(&mut ctx.env, 0) } else { event_time }; self.device_clock(event_time, oldest) } }; // `iris::input`'s own doc (`sense::log_input_event`): collected // only when tracing is on, since this is otherwise a `Vec` per // `MotionEvent` for a line nobody is reading -- the JNI reads // themselves (`historical_axis`/`historical_event_time_nanos` // below) already happen unconditionally, for the replay this // function does regardless of tracing. let trace_input = crate::diagnostics::trace_enabled(); let mut historical_ms: Vec<(u64, f32, f32)> = Vec::new(); // **Historical samples first.** A flick on a 120Hz screen is // delivered as one or two `MotionEvent`s with the intermediate // positions batched inside them, so reading only `x()`/`y()` threw // away every sample but the last: the velocity tracker saw one // `Pan` for the whole gesture, `VelocityTracker::velocity` answers // 0.0 below two samples, and the release therefore flung at zero -- // Iris's phone, twice ("fling still doesn't work"), while a // `ui-trace` swipe, which is many evenly-spaced events, flung fine. // Replayed one at a time through the sensors rather than summarised, // so the arbiter, the tracker and any other sensor all see the same // motion the finger actually made; only the last sample ends the // frame (`after_input`). if matches!(action, MotionAction::Move) { // Android documents the historical samples as oldest first and // the event's own sample as the newest of the batch; everything // downstream (`VelocityTracker`, `DragArbiter`'s long-press // clock) assumes it, so say so here rather than at each reader. // `DeviceClock::sample` is what asserts it, and it carries the // last sample seen *across* events, so the first sample of // every event is checked against the previous event's last one // rather than against the anchor. for pos in 0..history { let hx = event.historical_axis(&mut ctx.env, Axis::X, 0, pos); let hy = event.historical_axis(&mut ctx.env, Axis::Y, 0, pos); let ht = event.historical_event_time_nanos(&mut ctx.env, pos); let sample_at = clock.sample(ht); if trace_input { historical_ms.push((clock.ms_since_anchor(ht), hx, hy)); } let ui_state = self.state.android_state_mut(); ui_state.cursor.pos = vec2(hx, hy); ui_state.cursor.time = sample_at; self.run_input_frame(ctx); } } let event_at = clock.sample(event_time); let event_ms = clock.ms_since_anchor(event_time); self.device_clock = Some(clock); let ui_state = self.state.android_state_mut(); ui_state.cursor.time = event_at; match action { MotionAction::Down => { ui_state.cursor.pos = vec2(x, y); ui_state.cursor.exists = true; ui_state.cursor.buttons.left.update(true); } MotionAction::Move => { ui_state.cursor.pos = vec2(x, y); } MotionAction::Up => { ui_state.cursor.pos = vec2(x, y); ui_state.cursor.buttons.left.update(false); } // A cancel ends the press -- a release that never arrives // leaves whichever widget took pointer capture holding it // forever -- but it is **not** a release, and saying so is // `CursorState::cancelled`. It used to take the `Up` arm, so // the system's own swipe up from the bottom edge to leave the // app (moves, then `ACTION_CANCEL`) reached iris as a flick // released at speed, and the transcript flung while the app // was in the background: Iris's 2026-09-08 "leaving and // reopening the app also randomly moved the vertical scroll". MotionAction::Cancel => { ui_state.cursor.pos = vec2(x, y); ui_state.cursor.buttons.left.update(false); ui_state.cursor.cancelled = true; } _ => return false, } if trace_input { let action_word = match action { MotionAction::Down => "down", MotionAction::Move => "move", MotionAction::Up => "up", MotionAction::Cancel => "cancel", _ => "other", }; crate::sense::log_input_event(action_word, x, y, event_ms, &historical_ms); } self.after_input(ctx); true } fn on_focus_changed<'local>( &mut self, ctx: &mut CallbackCtx<'local>, gain_focus: bool, _direction: i32, _previously_focused_rect: Option<&Rect<'local>>, ) { self.drain_tasks(); if !gain_focus { let ui_state = self.state.android_state_mut(); if let Some(focus) = ui_state.focus.take() { focus.edit(&mut self.rsc).deselect(); } } self.after_input(ctx); } fn on_attached_to_window(&mut self, _ctx: &mut CallbackCtx) { self.drain_tasks(); } fn surface_changed<'local>( &mut self, ctx: &mut CallbackCtx<'local>, holder: &android_view::SurfaceHolder<'local>, _format: i32, width: i32, height: i32, ) { self.drain_tasks(); // The layout engine's own notion of the canvas size is separate // from the wgpu surface's -- winit's backend sets it from // `WindowEvent::Resized`, and there is no equivalent automatic // trigger here, so this is the one place android-view's surface // size has to be told to `UiRenderState` too. Missing this drew // nothing but the clear colour: the widget tree laid out against // whatever size `UiRenderState::new` starts at instead of the // surface's real one. // // **Physical pixels, matching `AndroidRenderer`'s own // `size()`/`resize()`/`new()`** -- `AndroidUiState::content_scale`'s // field comment. This call sets `UiRenderState::output_size`, which // every `rel`/`rest` length resolves against and every `abs` // pixel-region compares to directly; a `dp(56)` height now folds // in the density at `Len::apply_rest` time instead of this call // dividing the whole window into a separate logical space, which // is what used to make every `abs`-unit size (a fixed `.height(56)` // in particular) mean something different from a `rest`-based one. self.render.resize((width as f32, height as f32)); // **Reuse the existing renderer (device, atlas, buffers, bind // groups) when one is already live -- only reconfigure the // surface.** `surfaceChanged` fires on *every* size or format // change, not only on a genuinely new `Surface`/window: showing // the IME under `adjustResize` resizes the same `SurfaceView` and // is reported through this exact callback. Rebuilding the whole // `AndroidRenderer` here used to mean a fresh `UiRenderNode::new` // -- a brand-new, empty glyph atlas and fresh GPU buffers -- while // `iris_core`'s CPU-side glyph cache (`primitive/text.rs`) kept the // atlas coordinates it had already handed out against the *old* // atlas. Every glyph then drew from a UV rectangle that pointed // into a texture that had just been recreated empty, so text // vanished on the first keyboard open while rects (which never go // through the atlas) kept drawing -- exactly the "rectangles stay, // glyphs disappear" Iris reported. Confirmed by reading this path // end to end (no fresh-atlas rebuild anywhere in `resize()` below, // only in `AndroidRenderer::new`) before changing anything, per // AGENTS.md's "verify before finishing". // // `AndroidRenderer::resize` only reconfigures the wgpu surface and // rewrites the window uniform -- device, atlas, buffers and bind // groups are untouched, so the glyph cache's coordinates stay // valid. A genuinely new surface (after `surface_destroyed`, e.g. // backgrounding) still goes through `AndroidRenderer::new` below, // since `renderer` is `None` in that case. let already_live = self.state.android_state().renderer.is_some(); log::info!( "iris surface: surface_changed {width}x{height} already_live={already_live} \ glyphs_cached={} atlas_pages={}", self.rsc.ui.text.atlas.glyph_count(), self.rsc.ui.text.atlas.page_count(), ); if already_live { let ui_state = self.state.android_state_mut(); ui_state .renderer .as_mut() .expect("checked Some above") .resize(width as u32, height as u32); self.render(ctx, Instant::now()); return; } let window = holder.surface(&mut ctx.env).to_native_window(&mut ctx.env); // `AndroidRenderer::new` used to panic here through wgpu's own // default uncaptured-error handler on a bind-group-layout // validation failure -- exactly what aborted the P0 bench APK on // Iris's phone with the message truncated to "wgpu error: // Validation Error" and nothing else recoverable from the crash // report (RUST.md's P0 box, "iris bench crash on the phone, // 2026-09-06"). It now returns the full diagnostic instead; this is // the one place in the app that can turn it into something a // person can read, since `ctx.view`/`ctx.env` (needed to reach the // Java side) are only in scope inside a `ViewPeer` callback. // // `content_scale` reaches `AndroidRenderer` only for the // Diagnostics page's report text now -- window size and the // shader's window uniform are physical pixels throughout (see the // `resize` call above), not divided by it. let content_scale = self.state.android_state().content_scale; match AndroidRenderer::new(window, width as u32, height as u32, content_scale) { Ok(renderer) => { // A genuinely new renderer means a genuinely new GPU // device, holding none of the textures the old one did -- // while the CPU side of them (`UiData::textures`, and the // glyph atlas built on it) lives on `self.rsc` and // survives. So every slot has to be uploaded again, and // `Textures::reupload` queues exactly that, in slot order. // // It replaces clearing them, which threw away the *slot // numbering* as well as the pixels: every `TextureHandle` // a live widget still held -- one per icon or image on // screen, and one per folded card at the time -- then // named a slot nothing recognised, and the next frame // panicked in `image_bind_group` ("texture slot 89 is not // a live standalone image: None"). Re-uploading also keeps // the glyph atlas, so an app switch no longer re-rasterises // every glyph on screen. This only runs on the branch that // actually builds a new renderer, never on the reuse // branch above, where the textures are still on the device // that holds them. log::info!( "iris surface: new renderer built ({:?}), re-uploading textures: \ glyphs={} pages={}", renderer.adapter_backend, self.rsc.ui.text.atlas.glyph_count(), self.rsc.ui.text.atlas.page_count(), ); self.rsc.ui.textures.reupload(); self.state.android_state_mut().renderer = Some(renderer); self.render(ctx, Instant::now()); } Err(report) => { // One line for logcat (UI_RULES.md: "the full text for // whoever can read the log" lives here), the multi-line // original on screen -- `show_renderer_error` below. log::error!("iris renderer init failed: {}", report.replace('\n', " | ")); // Deferred, not called directly: `Activity::setContentView` // tears the old view hierarchy down synchronously, which // fires `IrisView`'s own `onFocusChanged` before // `setContentView` returns -- straight back into this same // `IrisViewPeer` through `on_focus_changed` while // `with_peer` (android-view's dispatch, `view.rs` upstream) // still holds this peer's `RefCell` borrow for the // `surface_changed` call in progress. Found by inducing a // validation error and hitting `RefCell already borrowed` // at exactly that reentrant call (RUST.md's P0 box). // `push_dynamic_deferred_callback` runs after `with_peer` // drops the borrow, which is what every other callback in // this file that reaches into Java already relies on // (`raise_if_enabled`, above). ctx.push_dynamic_deferred_callback(move |env, view| { show_renderer_error(env, view, &report); }); } } } fn surface_destroyed<'local>( &mut self, _ctx: &mut CallbackCtx<'local>, _holder: &android_view::SurfaceHolder<'local>, ) { log::info!( "iris surface: surface_destroyed, tearing the renderer down \ (glyphs_cached={} atlas_pages={})", self.rsc.ui.text.atlas.glyph_count(), self.rsc.ui.text.atlas.page_count(), ); self.state.android_state_mut().renderer = None; } fn do_frame(&mut self, ctx: &mut CallbackCtx, frame_time_nanos: i64) { self.drain_tasks(); // The vsync this frame is for, dated on the same ruler touch // samples are (`DeviceClock`), rather than `Instant::now()` here: // this callback runs some variable distance after that vsync -- // behind `drain_tasks`, behind whatever else the UI thread was // doing -- and anything advanced by that variable amount moves // unevenly between frames the display shows evenly. `at` rather // than `sample`, since a frame time is not part of the touch // samples' own ordering. let now = self .device_clock(frame_time_nanos, frame_time_nanos) .at(frame_time_nanos); self.render(ctx, now); } /// Where `AndroidRedrawHandle::request_redraw` (`android/render.rs`) /// actually lands: `View.postDelayed`'s Runnable resolves to this, on /// the UI thread, which is what makes it safe to call from a background /// task's own thread when `post_frame_callback`'s `Choreographer` /// requirement (a `Looper` on the *calling* thread) is not. Same body /// as `do_frame` -- draining tasks and rendering immediately is a /// perfectly good answer to "a background fetch has new state," and /// avoids a second frame-scheduling path to keep in sync with the real /// one. fn delayed_callback(&mut self, ctx: &mut CallbackCtx) { self.drain_tasks(); // No vsync to date this one on -- it is a background task's // "there is new state", not a frame the display asked for. self.render(ctx, Instant::now()); } fn as_input_connection(&mut self) -> Option<&mut dyn InputConnection> { Some(self) } fn as_accessibility_node_provider(&mut self) -> Option<&mut dyn AccessibilityNodeProvider> { Some(self) } } impl AccessibilityNodeProvider for IrisViewPeer { fn create_accessibility_node_info<'local>( &mut self, ctx: &mut CallbackCtx<'local>, virtual_view_id: jint, ) -> AccessibilityNodeInfo<'local> { let mut source = AndroidAccessSource { widgets: self.rsc.widgets(), render: &self.render, rsc: &self.rsc, }; let ui_state = self.state.android_state_mut(); AccessibilityNodeInfo(ui_state.access_adapter.create_accessibility_node_info( &mut source, &mut ctx.env, &ctx.view.0, virtual_view_id, )) } fn find_focus<'local>( &mut self, ctx: &mut CallbackCtx<'local>, focus_type: jint, ) -> AccessibilityNodeInfo<'local> { let mut source = AndroidAccessSource { widgets: self.rsc.widgets(), render: &self.render, rsc: &self.rsc, }; let ui_state = self.state.android_state_mut(); AccessibilityNodeInfo(ui_state.access_adapter.find_focus( &mut source, &mut ctx.env, &ctx.view.0, focus_type, )) } fn perform_action<'local>( &mut self, ctx: &mut CallbackCtx<'local>, virtual_view_id: jint, action: jint, arguments: &Bundle<'local>, ) -> bool { let Some(action) = accesskit_android::PlatformAction::from_java(&mut ctx.env, action, &arguments.0) else { return false; }; let ui_state = self.state.android_state_mut(); let Some(events) = ui_state.access_adapter.perform_action( &mut NullActionHandler, virtual_view_id, &action, ) else { return false; }; ctx.push_dynamic_deferred_callback(move |env, view| { raise_if_enabled(env, view, events); }); true } } /// Registers `IrisViewPeer`'s native methods and builds one on every /// `newViewPeer` call from Java. `State`'s app crate wraps this in a /// concrete `extern "system" fn` (a generic function cannot be handed to /// `register_view_class`, which wants a plain function pointer) -- see /// `iris/android-app/src/lib.rs`. pub fn new_peer<'local, State: AndroidAppState>( mut env: JNIEnv<'local>, view: View<'local>, context: Context<'local>, ) -> android_view::jni::sys::jlong { // `DisplayMetrics.density` -- physical pixels per dp on this device. // Read once here, at the one point in this file already handed a // `Context`, and carried on `AndroidUiState` from then on (see // `content_scale`'s field comment for what depends on it). let content_scale = context .resources(&mut env) .display_metrics(&mut env) .density(&mut env); log::info!("iris: new_peer content_scale={content_scale}"); let vm = env.get_java_vm().unwrap(); let global_view = env.new_global_ref(&view.0).unwrap(); let redraw: Arc = Arc::new(AndroidRedrawHandle::new(vm, global_view)); let (tasks, task_recv) = Tasks::init(redraw); let mut rsc = AndroidRsc { ui: Default::default(), events: Default::default(), tasks, state: Default::default(), _state: PhantomData, }; // See `TextData::density`'s field doc for why this is set alongside // `render.set_density` below rather than read from there. rsc.ui.text.density = content_scale; let shared = Rc::new(RefCell::new(Shared::default())); let ui_state = AndroidUiState::new(shared.clone(), content_scale); let mut state = State::new(ui_state, &mut rsc); let platform_vm = env.get_java_vm().unwrap(); let platform_view = env.new_global_ref(&view.0).unwrap(); state.platform_ready(&mut rsc, platform_vm, platform_view); let mut render = UiRenderState::new(); // Every `Len::dp` in the tree resolves against this from now on -- see // `UiRenderState::density`'s field doc and `Len::dp`'s. render.set_density(content_scale); let peer = IrisViewPeer { rsc, render, state, task_recv, device_clock: None, }; let id = android_view::register_view_peer(peer); super::insets::register(id, shared); id }