gallery/vendor/i-slint-backend-android-activity-1.18.1/lib.rs
Greg Pomerantz df4e1bd6f8 Baseline-60 control build: stock crates.io Slint, no vendored fixes
shim: 512 for aarch64-linux-android
Using package `gallery` in `/mnt/Backup/prog/android/gallery/Cargo.toml`
Compiling apk resources...
Linking apk resources...
Optimizing apk resources...
Compiling Java sources...
Creating classes.jar from compiled Java sources...
 'classes.dex'...
 'lib/arm64-v8a/libgallery.so'...
Verifying alignment of /mnt/Backup/prog/android/gallery/target/release/apk/gallery.apk (4)...
      49 AndroidManifest.xml (OK - compressed)
    1072 resources.arsc (OK)
    1153 classes.dex (OK - compressed)
    1635 lib/arm64-v8a/libgallery.so (OK - compressed)
Verification succesful
Signing `/mnt/Backup/prog/android/gallery/target/release/apk/gallery.apk` with keystore `/home/gmp/.android/debug.keystore`
APK: target/release/apk/gallery.apk (512) strips the [patch.crates-io] section so
the whole Slint stack (renderer, backend, core, wgpu-hal) resolves to
the stock 1.18.1 sources: no render-ahead, no vsync-locked render, no
app-owned frame driver, no wake-fd trampoline, no vkshim, no perf
instrumentation. All vendor-internal references in the app are now
#[cfg(feature = "perf")]-gated so the stock build is genuinely stock.

Purpose: the CONTROL experiment for the 120Hz zero-jank campaign.
If the grid still janks on the phone at 60 Hz with this build, the
jank lives in the app / GPU pipeline (drain tick, texture uploads,
model updates), not in the vendored fix stack.

60 Hz lock: NOT via JNI — the public API turned out to be
unavailable (Display.setRefreshRate is hidden-API-blocked;
WindowManagerImpl lacks setPreferredDisplayRefreshRate on both
tested devices). Instead the display is locked with the device's
own user setting:
(restored after the test). The baseline60 feature is a pure
marker; the build is otherwise identical to the app code.

Verified on emulator (x86_64, 60 Hz, SwiftShader): launch, grid
render, thumbnail fill, scroll + prefetch all work.
2026-10-30 22:05:28 -04:00

754 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright © SixtyFPS GmbH <info@slint.dev>
// 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<Weak<AndroidWindowAdapter>> = RefCell::new(Default::default());
}
pub struct AndroidPlatform {
app: AndroidApp,
window: Rc<AndroidWindowAdapter>,
event_listener: Option<Box<dyn Fn(&PollEvent<'_>)>>,
context: core::cell::OnceCell<i_slint_core::SlintContextWeak>,
}
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<Rc<dyn WindowAdapter>, 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<Box<dyn i_slint_core::platform::EventLoopProxy>> {
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<String> {
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<dyn FnOnce() + Send + 'static>),
}
type EventQueue = Arc<Mutex<Vec<Event>>>;
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<dyn FnOnce() + Send>,
) -> 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<i_slint_core::graphics::RequestedGraphicsAPI>,
) -> 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(())
}