From 38b81a40040d8ac83c7628676ad212a39d1573bd Mon Sep 17 00:00:00 2001 From: funman300 Date: Tue, 7 Jul 2026 10:31:56 -0700 Subject: [PATCH] fix(engine): poll decor-view size to catch fold resizes winit misses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit winit's Android backend does not forward content-rect changes that happen while backgrounded (open TODOs in winit 0.30 logged on every resume), so a fold/unfold cycle can leave Bevy rendering and laying out for the previous screen: tableau clipped off the left edge, bottom third empty (#130). Add a continuous decor-view size poller (JNI, every 30 frames) that, on mismatch with the cached Window resolution: writes the real physical size into window.resolution, emits a synthetic WindowResized in logical pixels, and re-arms the safe-area inset poller — covering both the fold-size and inset-staleness cases in one mechanism, without relying on AppLifecycle::WillResume being delivered (it may never be; a new evidence log in rearm_on_resumed settles that question on-device). Closes #130 Co-Authored-By: Claude Fable 5 --- solitaire_engine/src/safe_area.rs | 101 +++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/solitaire_engine/src/safe_area.rs b/solitaire_engine/src/safe_area.rs index 9c0c48b..bab5ede 100644 --- a/solitaire_engine/src/safe_area.rs +++ b/solitaire_engine/src/safe_area.rs @@ -84,7 +84,8 @@ impl Plugin for SafeAreaInsetsPlugin { #[cfg(target_os = "android")] app.init_resource::() .add_systems(Update, android::refresh_insets) - .add_systems(Update, android::rearm_on_resumed); + .add_systems(Update, android::rearm_on_resumed) + .add_systems(Update, android::refresh_surface_size); } } @@ -225,6 +226,7 @@ fn on_app_resumed( mod android { use super::{AppLifecycle, SafeAreaInsets}; use bevy::prelude::*; + use bevy::window::WindowResized; /// Tracks how many frames `refresh_insets` has polled. Stored as a /// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0 @@ -299,11 +301,108 @@ mod android { ) { for event in lifecycle.read() { if matches!(event, AppLifecycle::WillResume) { + // Evidence line for #130: winit's Android backend has open + // TODOs around forwarding resume notifications, so whether + // this ever fires on a given device is an open question. + info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll"); poll.0 = 0; } } } + /// Polls the decor view's size via JNI and forces a relayout when it + /// disagrees with Bevy's cached `Window` resolution (#130). + /// + /// winit's Android backend does not forward content-rect changes that + /// happen while the app is backgrounded (fold/unfold on foldables), so + /// after a fold cycle Bevy can keep rendering and laying out for the + /// previous screen's dimensions. Unlike `refresh_insets` this poller + /// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm + /// it, because that event is itself delivered through the same unreliable + /// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES` + /// frames is cheap. + /// + /// On a mismatch it: + /// 1. writes the real size into `window.resolution` so the renderer + /// reconfigures the surface and systems reading `window.width()` see + /// the truth, + /// 2. emits a synthetic `WindowResized` (logical pixels) so + /// `on_window_resized` in `table_plugin` recomputes the board layout, + /// 3. re-arms the inset poller, because a screen change almost always + /// moves the system bars too — covering the "re-poll never fires" + /// hole left open in #116. + pub(super) fn refresh_surface_size( + mut frame: Local, + mut windows: Query<(Entity, &mut Window)>, + mut resize_events: MessageWriter, + mut poll: ResMut, + ) { + const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps + + *frame += 1; + if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) { + return; + } + let Some((entity, mut window)) = windows.iter_mut().next() else { + return; + }; + + let (decor_w, decor_h) = match query_decor_size() { + Ok(size) => size, + Err(e) => { + // One-time note; the bridge simply isn't up yet during the + // first frames of a launch. + if *frame == POLL_INTERVAL_FRAMES { + warn!("safe_area: decor size query failed (will retry): {e}"); + } + return; + } + }; + if decor_w == 0 || decor_h == 0 { + return; // decor view not laid out yet + } + + // Reads go through `Deref` and do not trip change detection; only + // mutate `window` once a mismatch is confirmed. + let cached_w = window.resolution.physical_width(); + let cached_h = window.resolution.physical_height(); + if decor_w == cached_w && decor_h == cached_h { + return; + } + + info!( + "safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \ + {cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)" + ); + window.resolution.set_physical_resolution(decor_w, decor_h); + let scale = window.scale_factor(); + resize_events.write(WindowResized { + window: entity, + width: decor_w as f32 / scale, + height: decor_h as f32 / scale, + }); + poll.0 = 0; + } + + /// Physical pixel size of the activity's decor view — the ground truth + /// for the surface we are actually being displayed on, independent of + /// whatever winit last told Bevy. + fn query_decor_size() -> Result<(u32, u32), String> { + use solitaire_data::android_jni; + + android_jni::with_activity_env(|env, activity| { + let window = env + .call_method(activity, "getWindow", "()Landroid/view/Window;", &[])? + .l()?; + let decor = env + .call_method(&window, "getDecorView", "()Landroid/view/View;", &[])? + .l()?; + let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?; + let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?; + Ok((w.max(0) as u32, h.max(0) as u32)) + }) + } + fn query_insets() -> Result { use solitaire_data::android_jni; -- 2.47.3