From 0519b057d5a8d01a79188c55c9a75b3214e9d620 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 20 Aug 2026 18:53:29 +0200 Subject: [PATCH] fix(clients/android): a pad that was never there, and a picture in the corner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two field reports from one Android user, with one shape between them: a decision taken once, at a moment when the answer was still wrong, and never revisited. The console UI could not be dismissed. "With a controller" asks whether a pad is attached, and the client answered that with `isPad` — does this device's source class include gamepad or joystick. That is the right question for ROUTING an event and the wrong one for presence: devices publish inputs that claim the source class while being no such thing (OEM game-mode overlays, the gaming-phone shoulder triggers), and one of them is enough to pin the console UI on forever, because a pad that was never there can never disconnect. `pads()` now filters on `looksLikeController`: the source claim AND hardware behind it — a stick, a HAT, or the A/B face buttons — on a device the platform did not synthesize itself. The claim is cheap; the hardware is not. `isPad` keeps its looser meaning for the event lane, where it is correct. It is not a complete defence (an OEM device that declares BTN_GAMEPAD and two axes is indistinguishable from a pad at this layer), so the master switch stays the guaranteed way out — and the Controllers screen still lists everything real in one column or the other, which is where someone looks when the client's idea of "a pad is attached" disagrees with the room. The picture sat in the top-left corner. The ASurfaceControl layer composites into the SurfaceView's on-screen rectangle, read once at `surfaceCreated` — but the stream screen hides the system bars and switches the window to draw into the display cutout a frame or two later, and each of those grows the view under a surface that is never recreated. The layer went on painting at the size it started with, anchored at the origin. It passed on glass because a device whose bars were already hidden when the surface arrived never sees the gap. The size is now live: a packed atomic on the session handle, seeded by `nativeStartVideo`, re-reported by `nativeVideoSurfaceSize` from every `surfaceChanged`, and read by the layer before each present. One atomic load per frame, and rotation and multi-window come along for free. Verified: `:kit:cargoNdkClippy` (arm64 + armv7, deny warnings), `:kit:` and `:app:` unit tests, and the native crate's own suite. The new JNI symbol is exported in the built `.so`. --- .../io/unom/punktfunk/ControllersScreen.kt | 6 +- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 11 +++ .../kotlin/io/unom/punktfunk/kit/Gamepad.kt | 50 +++++++++++++- .../io/unom/punktfunk/kit/NativeBridge.kt | 12 ++++ .../io/unom/punktfunk/kit/PadPresenceTest.kt | 67 +++++++++++++++++++ .../native/src/decode/asc_presenter.rs | 8 +-- .../android/native/src/decode/async_loop.rs | 6 +- clients/android/native/src/decode/mod.rs | 12 ++-- .../native/src/decode/surface_control.rs | 63 +++++++++-------- .../android/native/src/decode/sync_loop.rs | 3 +- clients/android/native/src/session/connect.rs | 2 + clients/android/native/src/session/mod.rs | 59 +++++++++++++++- clients/android/native/src/session/planes.rs | 41 +++++++++++- 13 files changed, 290 insertions(+), 50 deletions(-) create mode 100644 clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadPresenceTest.kt diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt index ffe679d0..7022b5b8 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/ControllersScreen.kt @@ -96,7 +96,11 @@ internal fun ControllersScreen( InputDevice.getDeviceIds() .toList() .mapNotNull { InputDevice.getDevice(it) } - .filter { !it.isVirtual && !Gamepad.isPad(it) } + // Everything real that is NOT counted as a controller — including a device that claims + // a pad source with no pad hardware behind it, which the Gamepads list above now + // rejects. One list or the other, never neither: this screen is where someone looks + // when the client's idea of "a pad is attached" disagrees with the room. + .filter { !it.isVirtual && !Gamepad.looksLikeController(it) } } DisposableEffect(Unit) { val im = context.getSystemService(InputManager::class.java) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index 8f94ec47..3e35bf07 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -940,6 +940,17 @@ fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> U } override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + // The view's CURRENT pixel size, for the ASurfaceControl layer's + // destination rect. It is reported here and not only at + // surfaceCreated because the view grows a frame or two after the + // stream screen appears — hiding the system bars and switching on + // cutout drawing both resize it, and neither recreates the surface. + // A layer left on the start-up rect paints the picture small, in the + // top-left corner. The view's own size, not the buffer geometry in + // `width`/`height`: the layer composites in the view's space. + NativeBridge.nativeVideoSurfaceSize( + handle, this@apply.width, this@apply.height, + ) // Re-assert the frame-rate vote: a buffer-geometry change can reset // the surface's frame-rate setting on some OEM builds, silently // dropping the 120 Hz pin mid-stream. Mirrors the native hint's diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt index b19171b9..7b3cf6e5 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/Gamepad.kt @@ -193,9 +193,53 @@ object Gamepad { s and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK } - /** All connected gamepad/joystick [InputDevice]s, in system enumeration order. */ - fun pads(): List = - InputDevice.getDeviceIds().toList().mapNotNull { InputDevice.getDevice(it) }.filter { isPad(it) } + /** + * True when [dev] is a controller someone can actually hold: a pad source ([isPad]) that is a + * REAL device carrying real pad hardware — a stick, a HAT, or the A/B face buttons. + * + * [isPad] alone answers "did this event come from a pad source", which is the right question + * for ROUTING an event and the wrong one for "is a controller attached". Devices publish + * inputs that claim `SOURCE_GAMEPAD`/`SOURCE_JOYSTICK` while being no such thing — OEM + * game-mode overlays and the gaming-phone shoulder triggers among them — and one of those is + * enough to pin the console UI on forever: a pad that was never there cannot disconnect, so + * "With a controller" has no way back to the touch UI. + * + * The capability probe is what separates them: a source class is a claim, a stick or a face + * button is hardware. It is not a complete defence — an OEM device that declares `BTN_GAMEPAD` + * and a pair of axes is indistinguishable from a pad at this layer — so the master switch stays + * the guaranteed way out. `isVirtual` only means "device id < 0" (the platform's own synthetic + * device), which is worth excluding but catches none of the above. + */ + fun looksLikeController(dev: InputDevice?): Boolean { + val d = dev ?: return false + return looksLikeController( + padSource = isPad(d), + virtual = d.isVirtual, + hasStick = d.getMotionRange(MotionEvent.AXIS_X, InputDevice.SOURCE_JOYSTICK) != null || + d.getMotionRange(MotionEvent.AXIS_HAT_X, InputDevice.SOURCE_JOYSTICK) != null, + // `hasKeys` answers for the DEVICE, so a pad with no sticks at all (an arcade stick, + // a d-pad-only pad) still counts. + hasFaceButtons = d.hasKeys(KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_BUTTON_B) + .any { it }, + ) + } + + /** [looksLikeController]'s decision, over plain facts — the seam its truth table is tested at + * (an [InputDevice] cannot be built off a device). */ + fun looksLikeController( + padSource: Boolean, + virtual: Boolean, + hasStick: Boolean, + hasFaceButtons: Boolean, + ): Boolean = padSource && !virtual && (hasStick || hasFaceButtons) + + /** + * All connected controllers, in system enumeration order — the devices that answer "is a pad + * attached", so the filter is [looksLikeController] rather than the looser [isPad]. + */ + fun pads(): List = InputDevice.getDeviceIds().toList() + .mapNotNull { InputDevice.getDevice(it) } + .filter { looksLikeController(it) } /** First connected gamepad/joystick [InputDevice], or null when none is attached. */ fun firstPad(): InputDevice? = pads().firstOrNull() diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index 9032e17f..f5f9ed9b 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -298,6 +298,18 @@ object NativeBridge { surfaceH: Int, ) + /** + * Re-report the video SurfaceView's on-screen pixel size — call it from every `surfaceChanged`. + * + * The ASurfaceControl present backend composites the picture into exactly this rectangle, and + * the view grows AFTER [nativeStartVideo] has run: the stream screen hides the system bars and + * switches the window to draw into the display cutout a frame or two later, and neither + * recreates the surface. Without this the layer keeps painting at its start-up size in the + * corner of a now-bigger surface. Non-positive values are ignored. No-op on a `0` handle; + * cheap (one atomic store), UI-safe. + */ + external fun nativeVideoSurfaceSize(handle: Long, width: Int, height: Int) + /** Stop + join the decode thread without closing the session. No-op on `0`. */ external fun nativeStopVideo(handle: Long) diff --git a/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadPresenceTest.kt b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadPresenceTest.kt new file mode 100644 index 00000000..60091ee9 --- /dev/null +++ b/clients/android/kit/src/test/kotlin/io/unom/punktfunk/kit/PadPresenceTest.kt @@ -0,0 +1,67 @@ +package io.unom.punktfunk.kit + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The truth table behind "is a controller attached" — the question the console UI's + * "With a controller" mode is answered by. A false positive here is not cosmetic: it pins the + * console UI on with no pad in the room, and no setting short of turning the whole thing off can + * dismiss it, because the phantom pad never disconnects. + */ +class PadPresenceTest { + + /** A real pad: the source class plus hardware behind it, in either of the two shapes. */ + @Test + fun realPadsCount() { + assertTrue( + Gamepad.looksLikeController( + padSource = true, virtual = false, hasStick = true, hasFaceButtons = true, + ), + ) + // An arcade stick / d-pad-only pad — buttons, no analog stick. + assertTrue( + Gamepad.looksLikeController( + padSource = true, virtual = false, hasStick = false, hasFaceButtons = true, + ), + ) + // A wheel or flight stick — axes, no A/B. + assertTrue( + Gamepad.looksLikeController( + padSource = true, virtual = false, hasStick = true, hasFaceButtons = false, + ), + ) + } + + /** The gaming-phone shoulder triggers and OEM game-mode overlays: a virtual device wearing the + * gamepad source class. This is the field report — the console UI that could not be dismissed. */ + @Test + fun virtualDevicesAreNotControllers() { + assertFalse( + Gamepad.looksLikeController( + padSource = true, virtual = true, hasStick = true, hasFaceButtons = true, + ), + ) + } + + /** A device that claims a pad source with nothing behind it is not a pad either. */ + @Test + fun aSourceClaimWithoutHardwareIsNotAController() { + assertFalse( + Gamepad.looksLikeController( + padSource = true, virtual = false, hasStick = false, hasFaceButtons = false, + ), + ) + } + + /** And a keyboard/mouse with sticks it never reports on the joystick source stays out. */ + @Test + fun nonPadSourcesNeverCount() { + assertFalse( + Gamepad.looksLikeController( + padSource = false, virtual = false, hasStick = true, hasFaceButtons = true, + ), + ) + } +} diff --git a/clients/android/native/src/decode/asc_presenter.rs b/clients/android/native/src/decode/asc_presenter.rs index 0bf7cad7..6acd532b 100644 --- a/clients/android/native/src/decode/asc_presenter.rs +++ b/clients/android/native/src/decode/asc_presenter.rs @@ -142,21 +142,21 @@ pub(super) struct AscBackend { impl AscBackend { /// Create the reader + compositor layer, or `None` on API < 29 / init failure (the caller then /// runs the SurfaceView presenter). `window` is the SurfaceView's `ANativeWindow`; `src_w/h` the - /// negotiated decode size; `panel_hz` the mode-table panel rate (seeds the learner); + /// negotiated decode size; `surface_size` the LIVE view size the layer composites into; + /// `panel_hz` the mode-table panel rate (seeds the learner); /// `dataspace` the `ADataSpace` from the negotiated colour; `source_hz` the negotiated stream rate. #[allow(clippy::too_many_arguments)] pub(super) fn create( window: &NativeWindow, src_w: i32, src_h: i32, - surface_w: i32, - surface_h: i32, + surface_size: std::sync::Arc, panel_hz: i32, dataspace: i32, source_hz: u32, priority: PresentPriority, ) -> Option { - let layer = Layer::create(window, surface_w, surface_h)?; + let layer = Layer::create(window, surface_size)?; let usage = ndk::hardware_buffer::HardwareBufferUsage::GPU_SAMPLED_IMAGE | ndk::hardware_buffer::HardwareBufferUsage::COMPOSER_OVERLAY; let reader = match ImageReader::new_with_usage( diff --git a/clients/android/native/src/decode/async_loop.rs b/clients/android/native/src/decode/async_loop.rs index d489cb73..c5b579cb 100644 --- a/clients/android/native/src/decode/async_loop.rs +++ b/clients/android/native/src/decode/async_loop.rs @@ -96,8 +96,7 @@ pub(super) fn run_async( present_priority, smooth_buffer, panel_hz, - surface_w, - surface_h, + surface_size, } = opts; boost_thread_priority(); let mode = client.mode(); @@ -199,8 +198,7 @@ pub(super) fn run_async( &window, mode.width as i32, mode.height as i32, - surface_w, - surface_h, + surface_size, panel_hz, initial_ds, mode.refresh_hz, diff --git a/clients/android/native/src/decode/mod.rs b/clients/android/native/src/decode/mod.rs index a96e4375..524149bf 100644 --- a/clients/android/native/src/decode/mod.rs +++ b/clients/android/native/src/decode/mod.rs @@ -133,12 +133,12 @@ pub(crate) struct DecodeOptions { /// named here is not necessarily the one the panel ends up in. The measured timeline spacing /// corrects it in both directions ([`punktfunk_core::phase::PanelGrid`]). pub panel_hz: i32, - /// The video `SurfaceView`'s on-screen pixel size (the aspect-fitted display footprint), from - /// Kotlin at `surfaceCreated`. The ASurfaceControl backend composites its layer in this - /// coordinate space — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin - /// couldn't read it yet, and the backend falls back to the window buffer size. - pub surface_w: i32, - pub surface_h: i32, + /// The video `SurfaceView`'s LIVE on-screen pixel size (the aspect-fitted display footprint), + /// packed by [`crate::session::pack_surface_size`] and re-reported by Kotlin on every + /// `surfaceChanged`. The ASurfaceControl backend composites its layer in this coordinate space + /// — NOT the window's buffer geometry, which is rotated/scaled. `0` = Kotlin couldn't read it + /// yet, and the backend falls back to the window buffer size. + pub surface_size: std::sync::Arc, } /// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop. diff --git a/clients/android/native/src/decode/surface_control.rs b/clients/android/native/src/decode/surface_control.rs index 2f72a85f..5d42cbb6 100644 --- a/clients/android/native/src/decode/surface_control.rs +++ b/clients/android/native/src/decode/surface_control.rs @@ -24,6 +24,7 @@ use ndk::hardware_buffer::HardwareBuffer; use ndk::native_window::NativeWindow; use std::ffi::c_void; use std::os::fd::{FromRawFd, OwnedFd, RawFd}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{mpsc, Arc}; use super::async_loop::DecodeEvent; @@ -276,9 +277,14 @@ unsafe extern "C" fn on_complete(context: *mut c_void, stats: *mut ASurfaceTrans pub(super) struct Layer { api: Api, sc: Arc, - /// Destination rectangle (the SurfaceView's pixel size) — the buffer is scaled to fill it. - dest_w: i32, - dest_h: i32, + /// The SurfaceView's LIVE pixel size, packed by `pack_surface_size` and re-read before every + /// present — the destination rectangle the buffer is scaled to fill. Live rather than captured + /// because the view resizes under a surface that is never recreated (see `dest`). + surface_size: Arc, + /// Fallback destination for as long as `surface_size` is still `0` (Kotlin hadn't measured the + /// view when video started): the window's own buffer geometry, the best remaining guess. + fallback_w: i32, + fallback_h: i32, /// `true` once the first transaction has made the layer visible + set its z-order + frame rate. configured: bool, } @@ -287,13 +293,16 @@ impl Layer { /// Create the compositor layer over `window` (the SurfaceView's `ANativeWindow`), or `None` on /// API < 29 / a null layer — the caller then uses the SurfaceView presenter. /// - /// `dest_w/h` are the SurfaceView's **on-screen pixel size** — the coordinate space the child - /// layer is composited into, which is the display footprint of the (aspect-fitted) video view, - /// NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer geometry in a - /// rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — using it shrank - /// the picture to the top-left corner. A non-positive `dest_w/h` (Kotlin couldn't read the view - /// yet) falls back to that buffer size as the best remaining guess. - pub(super) fn create(window: &NativeWindow, dest_w: i32, dest_h: i32) -> Option { + /// `surface_size` carries the SurfaceView's **on-screen pixel size** — the coordinate space the + /// child layer is composited into, which is the display footprint of the (aspect-fitted) video + /// view, NOT the window's buffer size. `ANativeWindow_getWidth/Height` return the buffer + /// geometry in a rotated/scaled space (observed 1260×567 for a 2800×1260 full-bleed stream) — + /// using it shrank the picture to the top-left corner. It is read fresh on every present + /// because that view RESIZES mid-stream under a surface that is never recreated: the stream + /// screen hides the system bars and switches on cutout drawing a frame or two after + /// `surfaceCreated`, and each one grows it. An empty `surface_size` (Kotlin hadn't measured the + /// view yet) falls back to the buffer size as the best remaining guess. + pub(super) fn create(window: &NativeWindow, surface_size: Arc) -> Option { let api = Api::resolve()?; // SAFETY: `window.ptr()` is the live `ANativeWindow` the decode thread owns; the name is a // static NUL-terminated string; the call returns null on failure (checked). @@ -303,20 +312,11 @@ impl Layer { log::warn!("asc: createFromWindow returned null — falling back to SurfaceView"); return None; } - let dest_w = if dest_w > 0 { - dest_w - } else { - window.width().max(1) - }; - let dest_h = if dest_h > 0 { - dest_h - } else { - window.height().max(1) - }; + let fallback_w = window.width().max(1); + let fallback_h = window.height().max(1); log::info!( - "asc: layer created, dest {dest_w}x{dest_h} (window buffer {}x{})", - window.width(), - window.height(), + "asc: layer created, dest {:?} (window buffer {fallback_w}x{fallback_h})", + crate::session::unpack_surface_size(surface_size.load(Ordering::Relaxed)), ); Some(Layer { sc: Arc::new(ScHandle { @@ -324,12 +324,20 @@ impl Layer { release: api.ac_release, }), api, - dest_w, - dest_h, + surface_size, + fallback_w, + fallback_h, configured: false, }) } + /// The destination rectangle for this present: the live view size, or the window's buffer + /// geometry while Kotlin has reported nothing. + fn dest(&self) -> (i32, i32) { + crate::session::unpack_surface_size(self.surface_size.load(Ordering::Relaxed)) + .unwrap_or((self.fallback_w, self.fallback_h)) + } + /// Present one decoded buffer at `desired_present_ns` (`CLOCK_MONOTONIC`; `0` = ASAP). Consumes /// `acquire_fence` (ownership passes to SurfaceFlinger via `setBuffer`). Registers a one-shot /// completion that reports the real latch + the previous buffer's release fence on `ev_tx`, @@ -370,11 +378,12 @@ impl Layer { right: src_w.max(1), bottom: src_h.max(1), }; + let (dest_w, dest_h) = self.dest(); let dst = ARect { left: 0, top: 0, - right: self.dest_w, - bottom: self.dest_h, + right: dest_w, + bottom: dest_h, }; (self.api.txn_set_geometry)(txn, sc, &src, &dst, TRANSFORM_IDENTITY); if dataspace != 0 { diff --git a/clients/android/native/src/decode/sync_loop.rs b/clients/android/native/src/decode/sync_loop.rs index f578e42a..6a10ecdb 100644 --- a/clients/android/native/src/decode/sync_loop.rs +++ b/clients/android/native/src/decode/sync_loop.rs @@ -50,8 +50,7 @@ pub(super) fn run_sync( panel_hz: _, // The ASurfaceControl backend is async-loop only; the sync loop renders straight to the // SurfaceView, so it never needs the view's on-screen size. - surface_w: _, - surface_h: _, + surface_size: _, } = opts; boost_thread_priority(); let mode = client.mode(); diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 68f6ecca..2cabd57b 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -470,6 +470,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo // A fresh session is never muted (mute is per-session UI state, not a setting). mic_muted: Arc::new(std::sync::atomic::AtomicBool::new(false)), access_seq: std::sync::atomic::AtomicU32::new(0), + // Reported by Kotlin at `surfaceCreated` and on every resize after it. + surface_size: Arc::new(std::sync::atomic::AtomicU64::new(0)), }; Box::into_raw(Box::new(handle)) as jlong } diff --git a/clients/android/native/src/session/mod.rs b/clients/android/native/src/session/mod.rs index b1d21e5a..c3c12fa4 100644 --- a/clients/android/native/src/session/mod.rs +++ b/clients/android/native/src/session/mod.rs @@ -26,7 +26,7 @@ mod probe; use punktfunk_core::client::NativeClient; use std::panic::AssertUnwindSafe; -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; @@ -87,6 +87,37 @@ pub(crate) struct SessionHandle { /// `nativeAccessState` poll ([`access`]) — how the Kotlin poller tells a fresh update /// (the host's expiry warnings) arrived without holding a blocking event thread. pub(crate) access_seq: AtomicU32, + /// The video `SurfaceView`'s LIVE on-screen pixel size ([`pack_surface_size`]), written by + /// `nativeStartVideo` and by every `nativeVideoSurfaceSize` the `surfaceChanged` callback + /// sends, read by the ASurfaceControl presenter before each present. + /// + /// Shared and live rather than a start-time parameter because the view RESIZES under a surface + /// that is never recreated: hiding the system bars and switching the window to + /// `LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS` both happen a frame or two AFTER `surfaceCreated`, + /// and each one grows the video view. A destination rect captured once at creation then keeps + /// compositing the picture at its old, smaller size anchored at the layer's origin — the + /// "stream in the top-left corner" field report. `0` = nothing reported yet, and the layer + /// falls back to the window's buffer geometry. + pub surface_size: Arc, +} + +/// Pack a surface's pixel size into one `u64` — so the presenter reads width and height as a +/// single atomic load and can never see a torn pair (a new width against an old height). +/// Non-positive values pack as `0`, the "not reported yet" sentinel. +pub(crate) fn pack_surface_size(w: i32, h: i32) -> u64 { + if w <= 0 || h <= 0 { + return 0; + } + ((w as u64) << 32) | (h as u64 & 0xffff_ffff) +} + +/// The inverse of [`pack_surface_size`]: `None` for the `0` sentinel. +#[cfg_attr(not(target_os = "android"), allow(dead_code))] +pub(crate) fn unpack_surface_size(packed: u64) -> Option<(i32, i32)> { + if packed == 0 { + return None; + } + Some((((packed >> 32) as u32) as i32, (packed as u32) as i32)) } struct VideoThread { @@ -160,3 +191,29 @@ fn parse_hex32(s: &str) -> Option<[u8; 32]> { } Some(out) } + +#[cfg(test)] +mod tests { + use super::{pack_surface_size, unpack_surface_size}; + + /// The pair the presenter reads as one atomic load must survive the round trip — including a + /// size wider than a signed 16-bit value, which every panel this runs on now is. + #[test] + fn surface_size_round_trips() { + assert_eq!( + unpack_surface_size(pack_surface_size(2800, 1260)), + Some((2800, 1260)) + ); + assert_eq!(unpack_surface_size(pack_surface_size(1, 1)), Some((1, 1))); + } + + /// "Not reported yet" — and anything nonsensical — is the one sentinel, so the layer falls back + /// to the window's buffer geometry rather than composing into an empty rectangle. + #[test] + fn non_positive_sizes_are_the_sentinel() { + assert_eq!(pack_surface_size(0, 0), 0); + assert_eq!(pack_surface_size(1920, 0), 0); + assert_eq!(pack_surface_size(-1, 1080), 0); + assert_eq!(unpack_surface_size(0), None); + } +} diff --git a/clients/android/native/src/session/planes.rs b/clients/android/native/src/session/planes.rs index 510f7073..1bedda74 100644 --- a/clients/android/native/src/session/planes.rs +++ b/clients/android/native/src/session/planes.rs @@ -72,6 +72,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( let client = h.client.clone(); let sd = shutdown.clone(); let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate) + + // Seed the live view size with what the view measures right now; `surfaceChanged` keeps it + // current from here on (the bars hide and the cutout mode changes AFTER this call). + h.surface_size.store( + super::pack_surface_size(surface_w, surface_h), + std::sync::atomic::Ordering::Relaxed, + ); let opts = crate::decode::DecodeOptions { decoder_name: decoder, ll_feature, @@ -80,8 +87,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( present_priority, smooth_buffer, panel_hz: panel_fps, - surface_w, - surface_h, + surface_size: h.surface_size.clone(), }; let join = std::thread::Builder::new() .name("pf-decode".into()) @@ -93,6 +99,37 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( .resolve::() } +/// `NativeBridge.nativeVideoSurfaceSize(handle, width, height)` — the video `SurfaceView`'s +/// on-screen pixel size, re-reported on every `surfaceChanged`. +/// +/// The ASurfaceControl presenter composites its child layer into exactly this rectangle, and the +/// view resizes UNDER a surface that is never recreated: the stream screen hides the system bars +/// and asks to draw into the display cutout a frame or two after `surfaceCreated`, both of which +/// grow it. Without this the layer would keep painting the picture at its start-up size, in the +/// corner of a bigger surface. Non-positive values are ignored (they'd blank the picture). +/// No-op on a `0` handle. Stored whether or not video is running — the next `nativeStartVideo` +/// then starts from a measured view rather than the window's guess. Not android-gated: pure `jni` +/// + an atomic store, so it links on the host build too. +#[unsafe(no_mangle)] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSurfaceSize( + _env: EnvUnowned, + _this: JObject, + handle: jlong, + width: jni::sys::jint, + height: jni::sys::jint, +) { + jni_guard((), || { + let packed = super::pack_surface_size(width, height); + if handle == 0 || packed == 0 { + return; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.surface_size + .store(packed, std::sync::atomic::Ordering::Relaxed); + }) +} + /// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host /// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList` /// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`]. -- 2.54.0