diff --git a/runtime-patches.lock.json b/runtime-patches.lock.json index e6d1747b..d9af1628 100644 --- a/runtime-patches.lock.json +++ b/runtime-patches.lock.json @@ -4,7 +4,7 @@ "base_revision": "d653bc4493619ab979ccff165cf48c0c63b77d19", "patch": "tools/runtime-patches/makepad-settings.patch", "sha256": "3a79cddddc89dc4d064a9f0bdd17dc8111dcab9c796a334d95f61ecb9d5011aa", - "tree": "dafa573454b957f2f1183d517256d5a28e66487f", + "tree": "a9c02164806d633f8eb344de821d44e894907ba8", "product_fixes": [ "Settings integration: Android IME synchronization, accessibility and reduced motion, GLES program-cache validation, obscured-gesture cancellation and scroll-position observation.", "Contained-app isolation and secret-input restrictions come from the pinned upstream runtime; the Settings overlay preserves those gates.", @@ -52,7 +52,8 @@ "Launch-input hardening: the Studio connection and the OCTOS_PROXY app HTTP proxy, which Makepad itself acts on from an Android Intent extra, are honoured only when the build opts into launch-time development input (debuggable, or the manifest meta-data dev.makepad.LAUNCH_STUDIO). A shipped build ignores them and drops any Studio host a development build saved. The exported activity would otherwise let another installed app point it at a host or a proxy.", "Apple UI typeface (OctoSense-org/makepad#129): on macOS and iOS every text role leads with PingFang SC, read from the system and never packaged (Regular for regular and italic, Semibold for bold), fitted to the theme's line box so layouts keep their size; the bundled IBM Plex, LXGW WenKai and emoji chain stays behind it, code and icon roles keep their faces, and MAKEPAD_SYSTEM_FONTS=0 or another platform keeps today's chains. A font member can name a face of a collection (PingFang.ttc's face 0 is PingFang HK).", "CachedView can opt into an isolated depth buffer. Maps driving views use it so tilted terrain, roads and buildings are not hidden by host UI backgrounds, while route and navigation overlays retain normal composition.", - "Finance's first open (OctoSense-org/makepad#130): an empty file is seeded with the demo household in one transaction instead of one commit per row, so the first open no longer freezes the UI for seconds of per-row journal syncs (the first-start test: 1.8-3.3 s to 0.29 s in release); a failed seed leaves the file empty for the next start to seed again." + "Finance's first open (OctoSense-org/makepad#130): an empty file is seeded with the demo household in one transaction instead of one commit per row, so the first open no longer freezes the UI for seconds of per-row journal syncs (the first-start test: 1.8-3.3 s to 0.29 s in release); a failed seed leaves the file empty for the next start to seed again.", + "Android buffer-stuffing recovery (OctoSense-org/makepad#128): the Choreographer beat carries its expected presentation time (API 33+), EGL frame timestamps report each frame's display time, and when two frames in a row reach the screen half a refresh late while a finger is down one beat is left unpainted, so a drag stops trailing the finger by an extra frame after one slow frame. `setprop debug.makepad.stuffing off` disables it." ], "stacked": [ { @@ -210,6 +211,10 @@ { "patch": "tools/runtime-patches/makepad-finance-seed.patch", "sha256": "100fb98976ede125f64a7c78d8f8d51a902de9bfd0502fa08b06dfee6c9ca0fe" + }, + { + "patch": "tools/runtime-patches/makepad-android-buffer-stuffing.patch", + "sha256": "7f87b527e597c4b06908bfc265ec81d93d3d082cf773cfafdc5a31e3c782d817" } ] } diff --git a/tools/runtime-patches/makepad-android-buffer-stuffing.patch b/tools/runtime-patches/makepad-android-buffer-stuffing.patch new file mode 100644 index 00000000..9f9fe04f --- /dev/null +++ b/tools/runtime-patches/makepad-android-buffer-stuffing.patch @@ -0,0 +1,407 @@ +diff --git a/platform/src/os/linux/android/android.rs b/platform/src/os/linux/android/android.rs +index 07e6b35bd..ef9018802 100644 +--- a/platform/src/os/linux/android/android.rs ++++ b/platform/src/os/linux/android/android.rs +@@ -485,7 +485,16 @@ pub fn main_loop(&mut self, from_java_rx: mpsc::Receiver) { + // — which is what `destroy_surface` leaves us in — is + // undefined behavior and crashes Mali/Adreno drivers with + // a SIGSEGV inside `render_view`. +- if self.os.has_drawable_surface() { ++ if self.os.has_drawable_surface() ++ && super::android_stuffing::beat_begin( ++ super::android_jni::BEAT_FRAME_TIME_NS.load(std::sync::atomic::Ordering::Relaxed), ++ super::android_jni::BEAT_EXPECTED_PRESENT_NS.load(std::sync::atomic::Ordering::Relaxed), ++ ) ++ { ++ // Buffer-stuffing recovery leaves this beat unpainted ++ // so the queue drains; what it would paint waits for ++ // the next beat (android_stuffing.rs). ++ } else if self.os.has_drawable_surface() { + // If we previously skipped frames because the surface + // wasn't ready, the redraw request may have been consumed + // by an earlier draw cycle that couldn't actually paint. +@@ -940,6 +949,9 @@ pub(crate) fn handle_message(&mut self, msg: FromJavaMessage) { + } + FromJavaMessage::Touch { mut touches, cancelled, trusted } => { + if touches.is_empty() { return; } ++ super::android_stuffing::set_touch_active( ++ !cancelled && touches.iter().any(|t| t.state != crate::event::finger::TouchState::Stop), ++ ); + let time = touches[0].time; + let window = &self.windows[CxWindowPool::id_zero()]; + for touch in &mut touches { +@@ -1870,10 +1882,14 @@ fn present_window_for_active_backend(&mut self) { + let perf_t0 = self.perf_monitor.enabled().then(std::time::Instant::now); + if let Some(display) = &mut self.os.display { + if display.is_surface_alive() { ++ super::android_stuffing::before_swap(&display.libegl, display.egl_display, display.surface); + let swapped = (display.libegl.eglSwapBuffers.unwrap())( + display.egl_display, + display.surface, + ); ++ if swapped != 0 { ++ super::android_stuffing::swapped(); ++ } + // PerfMonitor "wait": eglSwapBuffers blocks on the buffer + // queue (and, on tilers, on the GPU finishing the frame). + if let Some(t0) = perf_t0 { +@@ -4189,6 +4205,7 @@ unsafe fn destroy_surface(&mut self) { + (self.libegl.eglDestroySurface.unwrap())(self.egl_display, self.surface); + } + self.surface = std::ptr::null_mut(); ++ super::android_stuffing::surface_reset(); + } + + #[cfg(not(use_vulkan))] +diff --git a/platform/src/os/linux/android/android_jni.rs b/platform/src/os/linux/android/android_jni.rs +index 6b7c9468c..6db02daad 100644 +--- a/platform/src/os/linux/android/android_jni.rs ++++ b/platform/src/os/linux/android/android_jni.rs +@@ -1542,6 +1542,23 @@ extern "C" fn Java_dev_makepad_android_MakepadNative_onDisplayDensity( + send_from_java_message(FromJavaMessage::DisplayDensity { density: density as f64 }); + } + ++/// The latest Choreographer beat's vsync time and its expected presentation ++/// time (CLOCK_MONOTONIC ns; 0 where Java does not provide it), set right ++/// before that beat's `RenderLoop` is sent (android_stuffing.rs). ++pub(crate) static BEAT_FRAME_TIME_NS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); ++pub(crate) static BEAT_EXPECTED_PRESENT_NS: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); ++ ++#[no_mangle] ++extern "C" fn Java_dev_makepad_android_MakepadNative_onFrameTime( ++ _: *mut jni_sys::JNIEnv, ++ _: jni_sys::jclass, ++ frame_time_nanos: jni_sys::jlong, ++ expected_present_nanos: jni_sys::jlong, ++) { ++ BEAT_FRAME_TIME_NS.store(frame_time_nanos, std::sync::atomic::Ordering::Relaxed); ++ BEAT_EXPECTED_PRESENT_NS.store(expected_present_nanos, std::sync::atomic::Ordering::Relaxed); ++} ++ + #[no_mangle] + extern "C" fn Java_dev_makepad_android_MakepadNative_onRenderLoop( + _: *mut jni_sys::JNIEnv, +diff --git a/platform/src/os/linux/android/android_stuffing.rs b/platform/src/os/linux/android/android_stuffing.rs +new file mode 100644 +index 000000000..cfa4350f9 +--- /dev/null ++++ b/platform/src/os/linux/android/android_stuffing.rs +@@ -0,0 +1,261 @@ ++//! Buffer-stuffing recovery for the Choreographer-paced GLES window. ++//! ++//! The render thread paints on each vsync beat and queues its buffer most of ++//! a refresh later (about 12 of 16.7 ms on a OnePlus 6), close to ++//! SurfaceFlinger's latch. One slow frame (often the first after idle, or a ++//! touch-down) misses the latch, and the next on-time frame queues behind it: ++//! from then on every frame waits an extra refresh in the buffer queue, until ++//! the app goes idle. While a finger drags content, that is a whole extra ++//! frame of lag behind it for the rest of the gesture. ++//! ++//! Each frame's display time comes back through ++//! EGL_ANDROID_get_frame_timestamps a few frames later. A frame is late when ++//! it reached the screen half a refresh or more after its beat's expected ++//! presentation time (Choreographer FrameData, API 33+; before that, the ++//! shortest recent beat-to-screen time stands in). Two late frames from ++//! consecutive beats mean the queue holds a frame too many: skipping one beat ++//! drains it, and the frames after it are on time again. A drained queue has ++//! no spare frame to absorb a slow one, so recovery runs only while a finger ++//! is down, where the lag shows; a stuffed queue during a fling's coast is ++//! invisible and is left alone. ++//! ++//! Any refresh without a new frame drains the queue the same way (an ++//! unpainted beat, or the beats an idle app stops asking for), so frames ++//! painted before one never count. Most motions end with the queue still ++//! stuffed, and their last frames' display times are read back only at the ++//! next gesture's first swap, where they would skip a beat for nothing. ++//! ++//! `setprop debug.makepad.stuffing off` turns it off (read once, at the first ++//! beat); the `android.stuffing` trace topic logs each detection and skip. ++use super::super::egl_sys::{EGLDisplay, EGLSurface, LibEgl}; ++use std::{collections::VecDeque, ffi::CStr}; ++ ++const EGL_EXTENSIONS: i32 = 0x3055; ++const EGL_TIMESTAMPS_ANDROID: i32 = 0x3430; ++const EGL_DISPLAY_PRESENT_TIME_ANDROID: i32 = 0x343a; ++const EGL_TIMESTAMP_PENDING_ANDROID: i64 = -2; ++/// Recent beat-to-screen times that set the on-time baseline (ten seconds ++/// at 60 Hz; idle frames count too, and they are never stuffed). ++const BASELINE_FRAMES: usize = 600; ++ ++type NextId = unsafe extern "C" fn(EGLDisplay, EGLSurface, *mut u64) -> u32; ++type Timestamps = unsafe extern "C" fn(EGLDisplay, EGLSurface, u64, i32, *const i32, *mut i64) -> u32; ++type Supported = unsafe extern "C" fn(EGLDisplay, EGLSurface, i32) -> u32; ++ ++#[derive(Default)] ++struct StuffingRecovery { ++ initialized: bool, ++ functions: Option<(NextId, Timestamps)>, ++ /// Swapped frames waiting for their display time: (frame id, beat, ++ /// the beat's expected presentation time or 0). ++ pending: VecDeque<(u64, i64, i64)>, ++ latencies: VecDeque, ++ /// Frames from beats at or before this were rendered before the last ++ /// skip, or before the last refresh without a new frame, and still carry ++ /// the old queue; they never trigger a skip. ++ armed_after: i64, ++ skip_next_beat: bool, ++ skips: u64, ++ /// Consecutive late frames from consecutive beats, and the last observed beat. ++ late_streak: u32, ++ last_observed_beat: i64, ++ /// A finger is on the screen. Only then does lag show (the content ++ /// trails the finger); after lift-off a stuffed queue is invisible and ++ /// its extra frame absorbs a slow one, so the queue is left alone. ++ touch_active: bool, ++ /// The beat being painted, the one before it, and recent beat intervals ++ /// (their minimum is the refresh period). ++ beat: i64, ++ expected: i64, ++ last_beat: i64, ++ intervals: VecDeque, ++ swapped_this_beat: bool, ++} ++ ++impl StuffingRecovery { ++ fn period(&self) -> i64 { ++ self.intervals.iter().copied().min().unwrap_or(16_666_667) ++ } ++ ++ unsafe fn initialize(&mut self, egl: &LibEgl, display: EGLDisplay, surface: EGLSurface) { ++ if self.initialized { ++ return; ++ } ++ self.initialized = true; ++ let extensions = (egl.eglQueryString.unwrap())(display, EGL_EXTENSIONS); ++ if extensions.is_null() ++ || !CStr::from_ptr(extensions).to_bytes().split(|b| *b == b' ').any(|s| s == b"EGL_ANDROID_get_frame_timestamps") ++ { ++ return; ++ } ++ let next = (egl.eglGetProcAddress.unwrap())(c"eglGetNextFrameIdANDROID".as_ptr()); ++ let timestamps = (egl.eglGetProcAddress.unwrap())(c"eglGetFrameTimestampsANDROID".as_ptr()); ++ let supported = (egl.eglGetProcAddress.unwrap())(c"eglGetFrameTimestampSupportedANDROID".as_ptr()); ++ if next.is_null() || timestamps.is_null() || supported.is_null() { ++ return; ++ } ++ let supported: Supported = std::mem::transmute(supported); ++ if supported(display, surface, EGL_DISPLAY_PRESENT_TIME_ANDROID) == 0 { ++ return; ++ } ++ if (egl.eglSurfaceAttrib.unwrap())(display, surface, EGL_TIMESTAMPS_ANDROID, 1) == 0 { ++ return; ++ } ++ self.functions = Some((std::mem::transmute(next), std::mem::transmute(timestamps))); ++ } ++ ++ /// Before swapping the frame painted for the vsync `beat` (CLOCK_MONOTONIC ++ /// ns, 0 when unknown): read back the display times that have arrived and ++ /// note this frame's id. ++ unsafe fn before_swap(&mut self, egl: &LibEgl, display: EGLDisplay, surface: EGLSurface) { ++ let (beat, expected, period) = (self.beat, self.expected, self.period()); ++ self.initialize(egl, display, surface); ++ let Some((next, timestamps)) = self.functions else { return }; ++ while let Some(&(id, frame_beat, frame_expected)) = self.pending.front() { ++ let mut present = 0i64; ++ if timestamps(display, surface, id, 1, &EGL_DISPLAY_PRESENT_TIME_ANDROID, &mut present) == 0 { ++ self.pending.pop_front(); ++ continue; ++ } ++ if present == EGL_TIMESTAMP_PENDING_ANDROID { ++ break; ++ } ++ self.pending.pop_front(); ++ if present > 0 && frame_beat > 0 { ++ self.observe(frame_beat, frame_expected, present, period); ++ } ++ } ++ if self.pending.len() >= 16 { ++ self.pending.pop_front(); ++ } ++ let mut id = 0u64; ++ if beat > 0 && next(display, surface, &mut id) != 0 { ++ self.pending.push_back((id, beat, expected)); ++ } ++ } ++ ++ /// A frame painted for `beat` reached the screen at `present`. Late: ++ /// half a refresh or more after the beat's expected presentation time ++ /// (Choreographer FrameData, API 33+), or before that API after the ++ /// shortest recent beat-to-screen time. ++ fn observe(&mut self, beat: i64, expected: i64, present: i64, period: i64) { ++ let latency = present - beat; ++ if latency <= 0 || latency > 8 * period { ++ return; ++ } ++ let late = if expected > 0 { ++ present - expected >= period / 2 ++ } else { ++ self.latencies.push_back(latency); ++ if self.latencies.len() > BASELINE_FRAMES { ++ self.latencies.pop_front(); ++ } ++ latency >= *self.latencies.iter().min().unwrap() + period / 2 ++ }; ++ // One late frame need not stuff the queue (an idle beat after it ++ // drains it), and its display time arrives frames later, when the ++ // queue may already be empty again. Stuffing makes EVERY following ++ // frame late, so act only on two in a row from consecutive beats. ++ let consecutive = beat - self.last_observed_beat <= period + period / 2; ++ self.last_observed_beat = beat; ++ self.late_streak = if late { if consecutive { self.late_streak + 1 } else { 1 } } else { 0 }; ++ if self.touch_active && beat > self.armed_after && self.late_streak >= 2 { ++ self.skip_next_beat = true; ++ self.late_streak = 0; ++ if crate::makepad_error_log::trace_enabled("android.stuffing") { ++ crate::log!("[android.stuffing] late beat={} present={} expected={} latency={}", beat, present, expected, latency); ++ } ++ } ++ } ++ ++ /// Whether to leave the beat `beat` unpainted. Only a beat that follows a ++ /// painted one is skipped: an idle app's next frame (a new gesture's ++ /// first) always paints. ++ fn take_skip(&mut self, beat: i64, painted_last_beat: bool) -> bool { ++ let skip = std::mem::take(&mut self.skip_next_beat) && painted_last_beat && self.touch_active; ++ if skip { ++ self.armed_after = beat; ++ self.skips += 1; ++ } ++ skip ++ } ++} ++ ++thread_local! { ++ static RECOVERY: std::cell::RefCell = std::cell::RefCell::new(StuffingRecovery::default()); ++} ++ ++fn enabled() -> bool { ++ static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); ++ *ENABLED.get_or_init(|| { ++ extern "C" { ++ fn __system_property_get(name: *const std::ffi::c_char, value: *mut std::ffi::c_char) -> std::ffi::c_int; ++ } ++ let mut value = [0u8; 92]; ++ let len = unsafe { __system_property_get(c"debug.makepad.stuffing".as_ptr(), value.as_mut_ptr() as *mut std::ffi::c_char) }; ++ let off = &value[..len.max(0) as usize] == b"off"; ++ crate::log!("[android.stuffing] recovery {}", if off { "off" } else { "on" }); ++ !off ++ }) ++} ++ ++/// A vsync beat that will paint: whether to leave it unpainted instead. ++pub(super) fn beat_begin(beat: i64, expected: i64) -> bool { ++ RECOVERY.with(|r| { ++ let mut r = r.borrow_mut(); ++ let painted_last_beat = std::mem::take(&mut r.swapped_this_beat); ++ let period = r.period(); ++ if r.last_beat > 0 && beat > r.last_beat && beat - r.last_beat < 100_000_000 { ++ let interval = beat - r.last_beat; ++ r.intervals.push_back(interval); ++ if r.intervals.len() > 32 { ++ r.intervals.pop_front(); ++ } ++ } ++ // The last beat went unpainted, or beats stopped (an idle app asks for ++ // none): the screen took a refresh without a new frame, which drained ++ // the queue, so the frames painted before it no longer describe it. ++ if r.last_beat > 0 && (!painted_last_beat || beat - r.last_beat > period + period / 2) { ++ r.armed_after = r.armed_after.max(r.last_beat); ++ } ++ r.last_beat = beat; ++ r.beat = beat; ++ r.expected = expected; ++ if !enabled() || beat <= 0 { ++ return false; ++ } ++ let skip = r.take_skip(beat, painted_last_beat); ++ if skip && crate::makepad_error_log::trace_enabled("android.stuffing") { ++ crate::log!("[android.stuffing] skip beat={} total={}", beat, r.skips); ++ } ++ skip ++ }) ++} ++ ++/// Right before eglSwapBuffers of the frame painted for the current beat. ++pub(super) unsafe fn before_swap(egl: &LibEgl, display: EGLDisplay, surface: EGLSurface) { ++ if enabled() { ++ RECOVERY.with(|r| r.borrow_mut().before_swap(egl, display, surface)); ++ } ++} ++ ++/// eglSwapBuffers queued the frame. ++pub(super) fn swapped() { ++ RECOVERY.with(|r| r.borrow_mut().swapped_this_beat = true); ++} ++ ++/// The window surface went away: frame ids belong to it. ++pub(super) fn surface_reset() { ++ RECOVERY.with(|r| { ++ let mut r = r.borrow_mut(); ++ r.initialized = false; ++ r.functions = None; ++ r.pending.clear(); ++ r.skip_next_beat = false; ++ }); ++} ++ ++/// Whether a finger is down, from each touch update. ++pub(super) fn set_touch_active(active: bool) { ++ RECOVERY.with(|r| r.borrow_mut().touch_active = active); ++} +diff --git a/platform/src/os/linux/android/mod.rs b/platform/src/os/linux/android/mod.rs +index 8a4c77000..7883a6698 100644 +--- a/platform/src/os/linux/android/mod.rs ++++ b/platform/src/os/linux/android/mod.rs +@@ -7,6 +7,7 @@ + pub mod android_camera_player; + pub mod android_file_dialog; + pub mod android_jni; ++pub mod android_stuffing; + pub mod android_keycodes; + pub mod android_media; + pub mod android_midi; +diff --git a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadNative.java b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadNative.java +index 520fdfa38..2a3790983 100644 +--- a/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadNative.java ++++ b/tools/cargo_makepad/src/android/java/dev/makepad/android/MakepadNative.java +@@ -25,17 +25,39 @@ public class MakepadNative { + private static final Choreographer.FrameCallback renderLoop = new Choreographer.FrameCallback() { + @Override + public void doFrame(long frameTimeNanos) { ++ onFrameTime(frameTimeNanos, 0); + if (onRenderLoop()) { + Choreographer.getInstance().postFrameCallback(this); + } + } + }; + ++ // API 33+: the same animation-phase beat, with the frame's expected ++ // presentation time, which buffer-stuffing recovery measures lateness ++ // against (android_stuffing.rs). ++ private static final Choreographer.VsyncCallback vsyncLoop = android.os.Build.VERSION.SDK_INT >= 33 ++ ? new Choreographer.VsyncCallback() { ++ @Override ++ public void onVsync(Choreographer.FrameData data) { ++ onFrameTime(data.getFrameTimeNanos(), ++ data.getPreferredFrameTimeline().getExpectedPresentationTimeNanos()); ++ if (onRenderLoop()) { ++ Choreographer.getInstance().postVsyncCallback(this); ++ } ++ } ++ } ++ : null; ++ + private static void startRenderLoop() { +- Choreographer.getInstance().postFrameCallback(renderLoop); ++ if (vsyncLoop != null) { ++ Choreographer.getInstance().postVsyncCallback(vsyncLoop); ++ } else { ++ Choreographer.getInstance().postFrameCallback(renderLoop); ++ } + } + + public native static boolean onRenderLoop(); ++ public native static void onFrameTime(long frameTimeNanos, long expectedPresentNanos); + + public native static void onBackPressed(); + public native static void onHomeIntent();