// 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; 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(); vsync.set_animating(animating); if animating { // When animating (scrolling/flinging), cap the poll timeout to 6ms // as a safety net in case a vsync wake is lost. Rendering still // only happens when `pending_redraw` is set (content actually // changed) — we do NOT force a redraw here, which would waste // GPU re-rendering an unchanged scene on every wake. let frame_duration = Duration::from_millis(6); 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. let pr_before = self.window.pending_redraw.get(); let locked = stg::VSYNC_LOCKED_RENDER .load(std::sync::atomic::Ordering::Relaxed) && animating; 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); } } 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(); // 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. // // FRESHNESS GUARD: with the early waker (vsync.rs), TWO // wakes can arrive per slot — the early one (~0.8 ms BEFORE // the vsync edge) and the Choreographer's late fallback // (~5 ms after it). The first one of each slot renders; // the second must NOT (double render = 2× the slot budget = // 60 Hz collapse). The intervals (measured): // late-after-early = ~5.8 ms → must be REJECTED // early-after-late = ~7.5 ms → must be ACCEPTED // same-kind = ~8.33 ms → ACCEPTED // Threshold `period − 1.5 ms` (≈6.8 ms) sits between 5.8 // and 7.5 with ~0.7-1.0 ms of jitter margin on both sides. // (A 0.7-period threshold rejects early-after-late and a // 1.0-period threshold locks the cadence onto the late // wakes — both measured to fragment the burst.) let __now_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::monotonic_ns(); let __period_ns = i_slint_renderer_skia::wgpu_30_surface::vkprobe::VSYNC_PERIOD_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 __min_gap = __period_ns.saturating_sub(1_500_000); let __fresh = __period_ns == 0 || __now_ns.saturating_sub(__last) >= __min_gap; if __fresh { i_slint_renderer_skia::wgpu_30_surface::vkprobe::LAST_RENDER_START_NS .store(__now_ns, std::sync::atomic::Ordering::Relaxed); } saw_wake && __fresh } 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(); } // 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. if prev_locked && !locked { 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()?; } prev_locked = locked; 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, ); } } 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(()) }