// Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 // cSpell: ignore androidwindowadapter javahelper #![doc = include_str!("README.md")] #![doc(html_logo_url = "https://slint.dev/logo/slint-logo-square-light.svg")] #![cfg_attr(not(target_os = "android"), allow(rustdoc::broken_intra_doc_links))] #![cfg(target_os = "android")] #![cfg_attr(slint_nightly_test, feature(non_exhaustive_omitted_patterns_lint))] #![cfg_attr(slint_nightly_test, warn(non_exhaustive_omitted_patterns))] mod androidwindowadapter; mod javahelper; mod vsync; pub mod stg; #[cfg(all(not(feature = "aa-06"), feature = "aa-05"))] pub use android_activity_05 as android_activity; #[cfg(feature = "aa-06")] pub use android_activity_06 as android_activity; #[cfg(all(not(feature = "aa-06"), feature = "aa-05"))] use ndk_08 as ndk; #[cfg(feature = "aa-06")] use ndk_09 as ndk; pub use android_activity::AndroidApp; use android_activity::PollEvent; use androidwindowadapter::AndroidWindowAdapter; use core::ops::ControlFlow; use core::time::Duration; use i_slint_core::api::{EventLoopError, PlatformError}; use i_slint_core::platform::{Clipboard, WindowAdapter}; use i_slint_renderer_skia::SkiaRendererExt; use std::cell::RefCell; use std::rc::{Rc, Weak}; use std::sync::{Arc, Mutex}; thread_local! { static CURRENT_WINDOW: RefCell> = RefCell::new(Default::default()); } pub struct AndroidPlatform { app: AndroidApp, window: Rc, event_listener: Option)>>, context: core::cell::OnceCell, } impl AndroidPlatform { /// Instantiate a new Android backend given the [`android_activity::AndroidApp`] /// /// Pass the returned value to [`slint::platform::set_platform()`](`i_slint_core::platform::set_platform()`) /// /// # Example /// ``` /// #[cfg(target_os = "android")] /// #[unsafe(no_mangle)] /// fn android_main(app: i_slint_backend_android_activity::AndroidApp) { /// slint::platform::set_platform(Box::new( /// i_slint_backend_android_activity::AndroidPlatform::new(app), /// )) /// .unwrap(); /// // ... your slint application ... /// } /// ``` pub fn new(app: AndroidApp) -> Self { let window = AndroidWindowAdapter::new(app.clone()); CURRENT_WINDOW.set(Rc::downgrade(&window)); Self { app, window, event_listener: None, context: Default::default() } } /// Instantiate a new Android backend given the [`android_activity::AndroidApp`] /// and a function to process the events. /// /// This is the same as [`AndroidPlatform::new()`], but it allow you to get notified /// of events. /// /// Pass the returned value to [`slint::platform::set_platform()`](`i_slint_core::platform::set_platform()`) /// /// # Example /// ``` /// #[cfg(target_os = "android")] /// #[unsafe(no_mangle)] /// fn android_main(app: i_slint_backend_android_activity::AndroidApp) { /// slint::platform::set_platform(Box::new( /// i_slint_backend_android_activity::AndroidPlatform::new_with_event_listener( /// app, /// |event| { eprintln!("got event {event:?}") } /// ), /// )) /// .unwrap(); /// // ... your slint application ... /// } /// ``` pub fn new_with_event_listener( app: AndroidApp, listener: impl Fn(&PollEvent<'_>) + 'static, ) -> Self { let mut this = Self::new(app); this.event_listener = Some(Box::new(listener)); this } } impl i_slint_core::platform::Platform for AndroidPlatform { fn create_window_adapter(&self) -> Result, PlatformError> { Ok(self.window.clone()) } fn run_event_loop(&self) -> Result<(), PlatformError> { let ctx = self .context .get() .and_then(|ctx| ctx.upgrade()) .expect("the event loop runs inside the context that owns this platform"); // 120Hz scroll fix (docs/BUGS/scroll-jank-120hz.md): enable the // render-ahead pipeline in the wgpu surface. It is only ACTIVE per- // frame while `locked` (VSYNC_LOCKED_RENDER && animating); unlocked // renders always present immediately. See RENDER_AHEAD / RENDER_AHEAD_ACTIVE. i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD .store(true, std::sync::atomic::Ordering::Relaxed); let vsync = vsync::VsyncDriver::new(self.app.create_waker()); // RENDER_AHEAD flush: tracks the previous iteration's locked state so // that when animation ends we do one final render() — which presents // the frame held by the render-ahead pipeline (otherwise the last // content change would never be displayed). let mut prev_locked = false; // Edge (ns) of the last vsync delivered to this thread via the // eventfd trampoline (vsync.rs). A value newer than this at // decision time means a wake was delivered THIS iteration — immune // to the ALooper wake-fd swallow (see vsync.rs note). let mut last_delivered_edge = 0u64; // DRIVER: the slot index (relative to the smooth grid anchor) we // last rendered for. One render per SLOT — the double-fire guard // for the grid-driven accept. Slot-index space (not edge-value // space): a late-callback re-anchor shifts edge VALUES by // ~0.2–0.5 ms and would make an already-rendered slot look new. let mut last_rendered_slot: i128 = i128::MIN; loop { let mut timeout = ctx.duration_until_next_timer_update(); // While animating, the vsync thread wakes this loop at each display refresh // so rendering tracks the refresh rate instead of a fixed poll timeout. // // We also treat an ACTIVE POINTER DRAG as "animating" (see // stg::pointer_recently_active): during a direct drag Slint has no // physics animation, so has_active_animations() is false and, without // this, the loop would sleep until the next timer (16 ms) between // pointer events → 16-30 ms frame gaps. Keeping the vsync driver // alive makes poll_events wake at each display refresh instead. let animating = self.window.window.has_active_animations() || stg::pointer_recently_active(); // Push (animating, dirty) to the vsync driver: the Choreographer // callback chain self-sustains while animating OR dirty // (vsync.rs) — the dirty leg is what keeps flings alive after // pointer inactivity expires the `animating` window. let dirty = self.window.pending_redraw.get(); vsync.set_state(animating, dirty); let pr_before = self.window.pending_redraw.get(); let locked = stg::VSYNC_LOCKED_RENDER .load(std::sync::atomic::Ordering::Relaxed) && animating; if locked { // DRIVER mode (2026-10-30): the render clock is owned by the // app, not the OS. The decision is grid-driven (see below) — // no wake required — so the poll is NON-BLOCKING (timeout 0); // the loop spins at ~3-4k iterations/s (a 250 µs park after // each no-render iteration, see end of loop) checking the // grid. A 250 µs hrtimer park cannot be lost the way an // ALooper timeout wake can (measured: poll(1) returning // 4-6 ms LATE from inside kernel/epoll — the residual // +4.5-6 ms present population of slt13), and the thread // stays HOT (EAS big-core placement; a sleeping thread idles // at 820 MHz on little cores). The Choreographer + eventfd // stay alive as the GRID SENSOR (VSYNC_EDGE_NS), never as // the render trigger. timeout = Some(Duration::ZERO); } else if animating || dirty { // When animating (scrolling/flinging) or dirty, cap the poll // timeout to 2ms. (2026-10-30: the cap was 6ms — but // ALooper_pollOnce(6) itself returns 2-5 ms LATE on this // device (measured: polls of 8.4-10.7 ms with to=6, no // context switches — the delay is inside kernel/epoll, // not app-side). One lost/late wake then ate a whole slot: // render start edge+6-8 ms → present edge+7-9 ms → the // residual 0.6% slip-frames. With a 2ms cap, even if a wake // is lost to a kernel delay the next poll picks it up // within 2 ms → render start edge+2-3 ms → present edge // +3-4 ms, inside the slot. Cost: ~200-400 extra idle // iterations/s during active scroll, each ~0.1-0.3 ms // (no render) ≈ a few % UI CPU. Emulator A/B (2ms vs 6ms, // 2026-10-30): no difference — 5-8 drops/capture both ways, // vs 47-147/capture for the pre-evfd build, so the evfd // wake-fd fix dominates and the shorter cap is kept.) let frame_duration = Duration::from_millis(2); timeout = Some(timeout.map_or(frame_duration, |x| x.min(frame_duration))); } // `request_redraw()` only raises the flag, so a request issued outside the // poll would otherwise wait here for an unrelated wakeup. // EXCEPT in vsync-locked mode: the render is deliberately deferred // to the vsync WAKE, so a zero timeout here would busy-spin the // loop at ~100 kHz (timeout event, no wake, flag never consumed) // for up to 8.3 ms after every input event. Block up to the cap // instead and let the wake arrive. if pr_before && !locked { timeout = Some(Duration::ZERO); } if pr_before && !locked { timeout = Some(Duration::ZERO); } // Jank-hunt: classify the timeout path to explain the 1451 iters/s // busy-spin. ZERO = forced by pending_redraw; CAP = 6 ms anim cap; // NATURAL = the next timer (>= ~7 ms). if stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) { let to_ms = timeout.map(|d| d.as_secs_f64() * 1000.0).unwrap_or(f64::MAX); let kind = if pr_before { 0 } else if animating && to_ms <= 6.0 { 1 } else { 2 }; stg::note_timeout_class(kind); } // STG (jank-hunt instrumentation): reset per-iteration marks. { let pr = stg::S_RENDER_START.load(std::sync::atomic::Ordering::Relaxed); let pe = stg::S_RENDER_END.load(std::sync::atomic::Ordering::Relaxed); if pr != 0 && pe != 0 { stg::S_LAST_RENDER_US .store(pe - pr, std::sync::atomic::Ordering::Relaxed); } } // PRE-POLL SPIN-RENDER (2026-10-31, finger-run beat fix #2): // the post-poll target spin alone could not pin the phase — // a queued finger event (1-3 ms to process) crossed the // edge+0.45 ms target DURING poll_events, so the spin found // the target already past and rendered at input-completion // time. Measured on the phone: phase histogram 0.4-3.0 ms // continuous (median 1.05) == the input-processing-duration // distribution, and the glass showed 10.5 ms beat gaps. // Fix: when the driver's target is within 1.2 ms and the slot // is unrendered, SKIP the poll (queued input rides to the // next slot, +8.3 ms — the standard vsync-locked input // latency, imperceptible) and spin to the exact target. if locked { let __p0 = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS .load(std::sync::atomic::Ordering::Relaxed); let __e0 = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS .load(std::sync::atomic::Ordering::Relaxed); let __n0 = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); if __p0 != 0 && __n0 >= __e0 { let __s0 = ((__n0 - __e0) as i128) / (__p0 as i128); if __s0 > last_rendered_slot { let __tgt = __e0 + (__s0 as u64) * __p0 + 450_000; if __n0 >= __tgt - 1_200_000 { while i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns() < __tgt { std::thread::yield_now(); } let __t = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .store(__t, std::sync::atomic::Ordering::Relaxed); last_rendered_slot = __s0; stg::S_START.store(stg::now_us(), std::sync::atomic::Ordering::Relaxed); stg::S_FIRST_EV.store(0, std::sync::atomic::Ordering::Relaxed); stg::S_TIMER_US.store(0, std::sync::atomic::Ordering::Relaxed); stg::S_EVENT_US.store(0, std::sync::atomic::Ordering::Relaxed); stg::S_N_EVENTS.store(0, std::sync::atomic::Ordering::Relaxed); ctx.update_timers_and_animations(); stg::S_RENDER_START.store(stg::now_us(), std::sync::atomic::Ordering::Relaxed); i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE .store(true, std::sync::atomic::Ordering::Relaxed); let __ok = self.window.do_render().is_ok(); stg::S_RENDER_END.store(stg::now_us(), std::sync::atomic::Ordering::Relaxed); // On error (surface gone during pause/destroy) // fall through instead of continuing: the // normal path below re-checks state, processes // the teardown events, and propagates the // error via its own `do_render()?`. if __ok { prev_locked = locked; continue; } } } } } let t_start = stg::now_us(); let timeout_ms = timeout.map(|d| d.as_millis() as u64).unwrap_or(u64::MAX); stg::S_START.store(t_start, std::sync::atomic::Ordering::Relaxed); stg::S_FIRST_EV.store(0, std::sync::atomic::Ordering::Relaxed); stg::S_TIMER_US.store(0, std::sync::atomic::Ordering::Relaxed); stg::S_EVENT_US.store(0, std::sync::atomic::Ordering::Relaxed); stg::S_N_EVENTS.store(0, std::sync::atomic::Ordering::Relaxed); let mut r = Ok(ControlFlow::Continue(())); // Jank-hunt / 120Hz: did this poll return because the vsync driver // (or any waker) fired? Used to lock rendering to the vsync edge // (see the render decision below) instead of rendering in reaction // to input events (which arrive at a random phase in the slot and // push the present past the ~5.5 ms compositor deadline → every // frame shown one vsync late → effective 60 Hz + judder). let mut saw_wake = false; // H5 (hypotheses-120hz.md): CPU time inside poll_events. wall≫cpu // ⇒ the UI thread slept/descheduled while the wake byte was // pending (epoll not woken, or the thread lost the CPU); wall≈cpu // ⇒ the loop spun inside ALooper. let __cpu_p0 = stg::thread_cpu_us(); self.app.poll_events(timeout, |e| { if matches!(e, PollEvent::Wake) { saw_wake = true; } if stg::S_FIRST_EV.load(std::sync::atomic::Ordering::Relaxed) == 0 { stg::S_FIRST_EV .store(stg::now_us(), std::sync::atomic::Ordering::Relaxed); } let t3_start = stg::now_us(); let t3_cpu_start = stg::thread_cpu_us(); r = self.window.process_event(&e); let t3_end = stg::now_us(); let t3_cpu_end = stg::thread_cpu_us(); let __dt3 = t3_end - t3_start; // Jank-hunt: count wake sources. If WAKE events dominate, the // loop is being woken far more often than vsync (120/s) or input, // which explains the 1600 iters/s busy-spin. match e { PollEvent::Wake => { stg::WAKE_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } PollEvent::Timeout => { stg::TIMEOUT_EV_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } PollEvent::Main(android_activity::MainEvent::InputAvailable) => { stg::INPUT_EV_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } _ => {} } // Jank-hunt: identify which MainEvent variant causes the big // `ev` spikes (pointer input is ~2.5 ms max, so a 20 ms event // is something else entirely). if __dt3 > 5_000 && stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) { let nm = match e { PollEvent::Main(android_activity::MainEvent::InputAvailable) => "Input", PollEvent::Main(android_activity::MainEvent::InitWindow { .. }) => "InitWin", PollEvent::Main(android_activity::MainEvent::TerminateWindow { .. }) => "TermWin", PollEvent::Main(android_activity::MainEvent::WindowResized { .. }) => "WinResized", PollEvent::Main(android_activity::MainEvent::RedrawNeeded { .. }) => "RedrawNeeded", PollEvent::Main(_) => "Main-other", _ => "NonMain", }; stg::log_slow_event(nm, __dt3, t3_cpu_end.saturating_sub(t3_cpu_start)); } stg::S_EVENT_US.fetch_add(__dt3, std::sync::atomic::Ordering::Relaxed); stg::S_N_EVENTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); if let Some(event_listener) = &self.event_listener { event_listener(&e) } }); let t_poll1 = stg::now_us(); let __poll_cpu = stg::thread_cpu_us().saturating_sub(__cpu_p0); let lv1 = stg::lv_snapshot(); // Wake-fd-race fix (2026-10-30): the ALooper wake byte can be // CONSUMED by Looper::pollInner while pollOnce returns a // co-batched input ident (system/core/libutils/Looper.cpp — the // POLL_WAKE result is a dropped local; see vsync.rs). The // dedicated eventfd trampoline runs inside poll_events and // publishes the delivered edge there — check it NOW, after. let __delivered = vsync.take_delivered_edge(); if __delivered > last_delivered_edge { saw_wake = true; last_delivered_edge = __delivered; } // Unconditionally update timers and animations on every event-loop wakeup ctx.update_timers_and_animations(); let t2 = stg::now_us(); stg::S_TIMER_US.store(t2 - t_poll1, std::sync::atomic::Ordering::Relaxed); stg::S_POLL_END.store(t2, std::sync::atomic::Ordering::Relaxed); if r?.is_break() { break; } let mut render_us = 0u64; let mut __render_cpu = 0u64; // Jank-hunt / 120Hz: vsync-locked rendering (the native-Android // model). While animating, input events only MARK the window dirty; // the render happens on the next vsync WAKE, so every present // starts at a deterministic phase (vsync edge + wake latency) and // lands well inside the compositor's ~5.5 ms deadline. Cost: up // to one slot of extra input latency. let do_render = if locked { // Present EVERY vsync while animating (not only on content // change): the regular 120 Hz present stream locks the // display cadence. Re-rendering unchanged content is cheap // with Skia's incremental pipeline. // // IMPORTANT: do NOT `pending_redraw.take()` here. The render // is driven by the vsync wake, but the pending flag also // carries the motion events' invalidations. Consuming it // here starves the follow-up unlocked iteration (which runs // 0.4 ms later, before the next motion event arrives) of // anything to render, collapsing the cadence to 60 Hz // (one render per two vsyncs). Letting the flag survive // means the next iteration renders too; if the content is // unchanged the extra render is invisible. // // SLOT-BASED WAKE GUARD (hypotheses-120hz.md, 2026-07-09 (3)). // Replaces the interval freshness guard, which had a 0.2 ms // false-reject margin (threshold 6.83 ms vs earliest // legitimate interval 7.03 ms after a Choreographer-phase // render) and — worse — could not express the real rule. // The rule: at most ONE render per display slot. A wake // belongs to slot floor((t − E)/P) (E = last observed vsync // edge, P = period; a stale E only shifts all indices by a // constant, so the comparison is exact). Reject only when // the wake's slot is the one already rendered; a wake N // slots later (after a spike or lost wakes) MUST render — // and the gap is logged as MISS with the wake state, which // is the missing evidence for the stall slots. let __now_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS .load(std::sync::atomic::Ordering::Relaxed); let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS .load(std::sync::atomic::Ordering::Relaxed); let __last = i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .load(std::sync::atomic::Ordering::Relaxed); let __slot = |t: u64| -> i128 { if t >= __e { ((t - __e) as i128) / (__p as i128) } else { -((( __e - t) as i128 + __p as i128 - 1) / (__p as i128)) } }; let __sw = if __p == 0 { 0 } else { __slot(__now_ns) }; let __sn = if __p == 0 || __last == 0 { i128::MIN } else { __slot(__last) }; if __p != 0 && __last != 0 && __sw >= __sn + 2 && stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) { stg::log_miss(__sw - __sn - 1, saw_wake, __now_ns, __sw, __sn, __now_ns.saturating_sub(__e) / 1000, __now_ns.saturating_sub(__last) / 1000); } // DIRTY BACKSTOP (2026-10-30): if the window is dirty and // the last render is >=2 slots old, render NOW regardless // of wake state (bounds any grid/sensor outage). let __dirty = self.window.pending_redraw.get(); let __age = __now_ns.saturating_sub(__last); let __backstop = __dirty && __last != 0 && __p != 0 && __age >= 2 * __p; // DRIVER (2026-10-30): the APP owns the render clock. Accept // when we are in a slot we have not rendered yet AND at // least 0.3 ms past that slot's edge. No saw_wake: the 1 ms // micro-park keeps `now` within ~1.3 ms of the grid, so the // present (render-ahead, at render start) lands at // edge+~1.5 ms with ~6.8 ms of latch margin, every frame, // regardless of Choreographer/wake delivery. The grid is the // SMOOTH anchor (note_vsync_edge: a late callback that fits // the extrapolation does not move it), so the slot index // here is stable across late callbacks — one render per // slot, never a double. A render overrun lands the next // slot one slot late. If the grid is dead (__p==0 or // __now < __e) the driver is off and the saw_wake path // governs. let __ph = if __p != 0 && __now_ns >= __e { (__now_ns - __e) % __p } else { 0 }; let __driver = __p != 0 && __now_ns >= __e + 300_000 && __ph >= 300_000 && __sw > last_rendered_slot; let __wake_ok = saw_wake && (__p == 0 || __sw > __sn); let __accept = __driver || __wake_ok || __backstop; if __accept { // PRESENT TARGET (2026-10-30, finger-run beat fix): // firing at the FIRST opportunity (now >= edge+0.3ms) // makes the present phase = whatever the 250us park + // the finger's 120-240 Hz input events (1-3 ms each) // left us — measured: phase p10-p90 1.23 ms, 1019 // phase-jumps>0.5ms in one finger run, and the glass // showed 10.5 ms inter-frame gaps (a 2 ms beat) 20 // times in 11 s. Instead: spin to the EXACT target // (this slot's edge + 0.45 ms) before rendering. The // spin is bounded (<= ~1.2 ms from detection, a // sched_yield loop), and the present phase becomes // edge+0.45ms +/- ~50 us — constant, no beat, no // matter when the input events arrive. The wake path // already lands at ~edge+0.5 (the wake's own phase), // so only the driver path needs the target. if __driver { let __slot_edge = __e + (__sw as u64) * __p; let __target = __slot_edge + 450_000; while i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns() < __target { std::thread::yield_now(); } } i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .store(__now_ns, std::sync::atomic::Ordering::Relaxed); if __sw > last_rendered_slot { last_rendered_slot = __sw; } } __accept } else { self.window.pending_redraw.get() }; if do_render { // Locked (vsync-wake) renders leave the flag set so the // follow-up unlocked iteration renders too (60 Hz collapse, // see the do_render note); unlocked renders consume it. if !locked { self.window.pending_redraw.take(); // Unlocked renders participate in the slot bookkeeping // too: without this, a locked→unlocked flicker mid-drag // leaves last_rendered_slot stale and the re-locked // driver re-renders the slot the unlocked render just // painted (double-present). let __t = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .store(__t, std::sync::atomic::Ordering::Relaxed); let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS .load(std::sync::atomic::Ordering::Relaxed); let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS .load(std::sync::atomic::Ordering::Relaxed); if __p != 0 && __t >= __e { let s = ((__t - __e) as i128) / (__p as i128); if s > last_rendered_slot { last_rendered_slot = s; } } } // RENDER_AHEAD (hold-without-present) is DISABLED: the held // frame is presented at the start of the NEXT render, i.e. // after the next vsync wake's Choreographer latency // (measured 3.2–6 ms on this device) plus the present cost — // landing at vsync+12.4–15.2 ms against a 13.8 ms latch // deadline: a 1.4 ms margin that missed 52% of frames // (one slot late) during real slow scrolls. Presenting at // the END of the render instead still left 51% one slot // late — under the model THEN believed (callback 3-6 ms // late). That model was WRONG: the callback fires at // edge+0.5 ms (measured, VSYNC mon−ftn). See // docs/BUGS/hypotheses-120hz.md. // // RENDER-AHEAD (re-enabled under the corrected model): the // wake arrives ON the edge, so the held frame — presented // at the START of this render — lands at edge+~1.5 ms: // constant phase, ~4 ms of deadline margin, and render // SPIKES (Skia 5-10 ms) stop delaying visible frames: a // spike only stretches the PREPARE phase of the frame held // for the NEXT slot (presented at the NEXT edge+1.5 ms, // same phase). Cost: one slot of constant latency // (imperceptible). i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE .store(locked, std::sync::atomic::Ordering::Relaxed); // PRESENT GATE: DISABLED (UI-thread sleep ate the slot // budget — history in hypotheses-120hz.md). Reference only. i_slint_renderer_skia::wgpu_30_surface::vkprobe::PRESENT_GATE_ON .store(false, std::sync::atomic::Ordering::Relaxed); stg::S_RENDER_START.store(t2, std::sync::atomic::Ordering::Relaxed); // Spike classifier (hypotheses-120hz.md): CPU time inside // do_render. rcpu≈render ⇒ Skia/wgpu CPU work; rcpu≪render ⇒ // blocked on the GPU/driver (queue back-pressure, fences). let __rc0 = stg::thread_cpu_us(); self.window.do_render()?; let t3 = stg::now_us(); stg::S_RENDER_END.store(t3, std::sync::atomic::Ordering::Relaxed); render_us = t3 - t2; __render_cpu = stg::thread_cpu_us().saturating_sub(__rc0); } else { stg::S_RENDER_END.store(0, std::sync::atomic::Ordering::Relaxed); } // RENDER_AHEAD flush (see above): on the locked → unlocked // transition, render once more to present the held frame — // EXCEPT when this iteration already rendered (unlocked, dirty): // that render presented the latest content immediately, so the // flush would only double-present the slot. (2026-10-30: this // double fired on every animating-flicker during bursty drags.) let mut flushed_now = false; if prev_locked && !locked && !do_render { self.window.pending_redraw.take(); i_slint_renderer_skia::wgpu_30_surface::vkprobe::RENDER_AHEAD_ACTIVE .store(false, std::sync::atomic::Ordering::Relaxed); self.window.do_render()?; // Bookkeeping (2026-10-30): the flush render presents the // current slot; without marking it, the re-locked driver // re-renders the same slot ~1 ms later (the +1.1/+1.2 // double-presents of slt13). let __t = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .store(__t, std::sync::atomic::Ordering::Relaxed); let __p = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_NS .load(std::sync::atomic::Ordering::Relaxed); let __e = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_EDGE_NS .load(std::sync::atomic::Ordering::Relaxed); if __p != 0 && __t >= __e { let s = ((__t - __e) as i128) / (__p as i128); if s > last_rendered_slot { last_rendered_slot = s; } } flushed_now = true; } // DRIVER spin throttle (2026-10-30): see the timeout note. After // a no-render locked iteration, park 250 µs (hrtimer — cannot be // swallowed like an ALooper timeout) so the grid is re-checked // within ~0.3 ms of each edge. if locked && !do_render && !flushed_now { std::thread::sleep(Duration::from_micros(250)); } prev_locked = locked; // Miss forensics (CONDITIONAL — 2026-07-09): the per-iteration // /proc read was itself a confounder (procfs reads cost tens of // µs on the UI thread; with render completion near the slot // boundary that pushed the present phase across it — 10x worse // drops/doubles measured). Sample only around SLOW iterations // (>5 ms wall): those are exactly the miss candidates, and the // common path pays nothing. let __iter_wall = stg::now_us().saturating_sub(t_start); let (__vsw, __nvol): (u64, u64) = if __iter_wall > 5_000 && stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) { // Delta against the rolling baseline (previous slow sample). let c = stg::ctxsw_sample(); let pv = stg::CTX_PREV_V.load(std::sync::atomic::Ordering::Relaxed); let pn = stg::CTX_PREV_NV.load(std::sync::atomic::Ordering::Relaxed); let first = !stg::CTX_SAMPLED.swap(true, std::sync::atomic::Ordering::Relaxed); stg::CTX_PREV_V.store(c.0, std::sync::atomic::Ordering::Relaxed); stg::CTX_PREV_NV.store(c.1, std::sync::atomic::Ordering::Relaxed); if first { (0, 0) } else { (c.0.saturating_sub(pv), c.1.saturating_sub(pn)) } } else { (0, 0) }; if stg::IT_ON.load(std::sync::atomic::Ordering::Relaxed) { let first_ev = stg::S_FIRST_EV.load(std::sync::atomic::Ordering::Relaxed); let lv2 = stg::lv_snapshot(); stg::log_iter( t_start, t_poll1 - t_start, stg::S_N_EVENTS.load(std::sync::atomic::Ordering::Relaxed), if first_ev == 0 { 0 } else { first_ev - t_start }, t2 - t_poll1, stg::S_EVENT_US.load(std::sync::atomic::Ordering::Relaxed), render_us, animating, timeout_ms, lv1, lv2, stg::flick_snapshot(), stg::input_snapshot(), stg::timeout_snapshot(), stg::wake_snapshot(), (locked, saw_wake, pr_before, do_render), __poll_cpu, __render_cpu, __vsw, __nvol, ); } } Ok(()) } fn new_event_loop_proxy(&self) -> Option> { Some(Box::new(AndroidEventLoopProxy { event_queue: self.window.event_queue.clone(), waker: self.app.create_waker(), })) } fn set_clipboard_text(&self, text: &str, clipboard: Clipboard) { if clipboard == Clipboard::DefaultClipboard { self.window .java_helper .set_clipboard(text) .unwrap_or_else(|e| javahelper::print_jni_error(&self.app, e)); } } fn clipboard_text(&self, clipboard: Clipboard) -> Option { if clipboard == Clipboard::DefaultClipboard { Some( self.window .java_helper .get_clipboard() .unwrap_or_else(|e| javahelper::print_jni_error(&self.app, e)), ) } else { None } } fn bind_context(&self, ctx: i_slint_core::SlintContextWeak, _: i_slint_core::InternalToken) { let _ = self.context.set(ctx.clone()); let ctx = ctx.upgrade().expect("bind_context called while the SlintContext is still alive"); let color_scheme = match self .window .java_helper .color_scheme() .unwrap_or_else(|e| javahelper::print_jni_error(&self.app, e)) { 0x10 => i_slint_core::items::ColorScheme::Light, // UI_MODE_NIGHT_NO 0x20 => i_slint_core::items::ColorScheme::Dark, // UI_MODE_NIGHT_YES _ => i_slint_core::items::ColorScheme::Unknown, }; ctx.set_color_scheme(color_scheme); if let Ok(accent) = self.window.java_helper.accent_color() { ctx.set_accent_color(accent); } if let Ok(scale) = self.window.java_helper.font_scale() && let Some(size) = javahelper::font_scale_to_logical_length(scale) { ctx.set_platform_default_font_size(Some(size)); } } fn long_press_interval(&self, _: i_slint_core::InternalToken) -> Duration { self.window.java_helper.long_press_timeout().unwrap_or(Duration::from_millis(500)) } } enum Event { Quit, Other(Box), } type EventQueue = Arc>>; struct AndroidEventLoopProxy { event_queue: EventQueue, waker: android_activity::AndroidAppWaker, } impl i_slint_core::platform::EventLoopProxy for AndroidEventLoopProxy { fn quit_event_loop(&self) -> Result<(), EventLoopError> { self.event_queue.lock().unwrap().push(Event::Quit); self.waker.wake(); Ok(()) } fn invoke_from_event_loop( &self, event: Box, ) -> Result<(), EventLoopError> { let n = crate::stg::INVK_PUSH_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; crate::stg::log_line(&format!("INVK n={n}")); self.event_queue.lock().unwrap().push(Event::Other(event)); self.waker.wake(); Ok(()) } } pub fn set_requested_graphics_api( requested_graphics_api: Option, ) -> Result<(), PlatformError> { let Some(adapter) = CURRENT_WINDOW.with_borrow(|x| x.upgrade()) else { return Err(format!("On Android a graphics API for Slint can only be requested after calling slint::android::init()").into()); }; adapter.set_requested_graphics_api(requested_graphics_api); Ok(()) }