From 91def8221990d6e13064480654e2ae8d27621398 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 29 Jul 2026 23:41:46 +0200 Subject: [PATCH] fix(client/android): the menus keep their safe area after a stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning from a stream left every menu laid out against the wrong safe area: content shoved right by the landscape side inset, the profile row sliding under the status bar, the tab labels crowding the gesture pill. Compose attaches its OnApplyWindowInsets AND WindowInsetsAnimation callbacks when the first composable reads an inset, and tears both down when the last reader goes away (WindowInsetsHolder.increment / decrementAccessors). The immersive stream reads no insets at all — it is a bare full-screen surface — so entering one dropped the reader count to zero right in the middle of the hide(systemBars()) animation StreamScreen had just started. With the animation callback gone, that animation's onEnd never arrived, so the listener kept runningAnimation = true for the rest of the process, and from then on every onApplyWindowInsets was swallowed (it defers to an onProgress that can no longer come). The values froze at the last animation frame — landscape, bars hidden — and that is what the menus got when they came back. Hold one inset reader at the root for the activity's whole life: the listeners now survive the stream, the landscape lock and the bar animations, and every animation gets its onEnd. It subscribes to no inset VALUE, so it costs nothing per frame. As a backstop, request a fresh insets pass from onConfigurationChanged — the activity declares configChanges=orientation|screenSize, so a rotation re-lays out in place and a dropped dispatch would otherwise go unnoticed until the layout is already wrong on screen. Co-Authored-By: Claude Opus 5 (1M context) --- .../kotlin/io/unom/punktfunk/MainActivity.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt index 8163af3f..7d61dec6 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/MainActivity.kt @@ -6,6 +6,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager +import android.content.res.Configuration import android.hardware.usb.UsbManager import android.os.Build import android.os.Bundle @@ -18,10 +19,14 @@ import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.systemBars import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import io.unom.punktfunk.kit.Gamepad @@ -36,6 +41,33 @@ import io.unom.punktfunk.kit.security.KnownHostStore /** Broadcast action for the menu-time SC2 USB-permission grant (see [MainActivity.startSc2MenuNav]). */ private const val SC2_MENU_PERMISSION = "io.unom.punktfunk.SC2_MENU_USB_PERMISSION" +/** + * Keeps ONE window-insets reader alive for as long as the app's UI exists — the fix for the menus + * coming back from a stream laid out against the WRONG safe area. + * + * Compose attaches its `OnApplyWindowInsets` **and** `WindowInsetsAnimation` callbacks when the + * first composable reads an inset, and removes them again when the last reader goes away + * (`WindowInsetsHolder.increment/decrementAccessors`). The immersive stream reads no insets at all + * — it's a bare full-screen surface — so entering one dropped the reader count to zero right in + * the middle of the `hide(systemBars())` animation [StreamScreen] had just started. With the + * animation callback gone, that animation's `onEnd` never reached Compose's listener, which kept + * `runningAnimation = true` for the rest of the process: from then on EVERY `onApplyWindowInsets` + * was swallowed (it defers to `onProgress`, which can no longer arrive) and the inset values stayed + * frozen at the last animation frame — the landscape, bars-hidden ones. Coming back out, the menus + * were laid out against those: content shoved right by the landscape side inset, and nothing kept + * clear of the status bar or the gesture pill (reported on-glass 2026-07-29). + * + * Reading an inset here costs nothing and holds the count above zero for the activity's whole life, + * so the listeners survive the stream, the rotation and the bar animations — and every animation + * gets its `onEnd`. + */ +@Composable +private fun HoldWindowInsetsListeners() { + // The read itself IS the registration (the accessor is scoped to this composable, which never + // leaves the composition); `remember` is only what keeps it from being a value nobody uses. + remember(WindowInsets.systemBars) {} +} + class MainActivity : ComponentActivity() { /** * The active stream session handle (0 = not streaming). Set by [StreamScreen] while it's shown. @@ -207,6 +239,7 @@ class MainActivity : ComponentActivity() { } setContent { PunktfunkTheme { + HoldWindowInsetsListeners() // Focus hook for the SC2's synthetic navigation (see [sc2MoveFocus]). `Next` is // the bootstrap: directional moves need an already-focused node, while one- // dimensional traversal assigns initial focus when there is none. @@ -237,6 +270,18 @@ class MainActivity : ComponentActivity() { private fun deepLinkFrom(intent: Intent?): String? = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data?.toString() + /** + * This activity declares `configChanges=orientation|screenSize|…`, so a rotation never recreates + * it — the window is re-laid-out in place. The safe area changes with every one of those, and a + * dropped insets dispatch is invisible until the layout is already wrong on screen, so ask for a + * fresh pass whenever the configuration moves. One traversal, and it re-syncs the insets after + * the stream releases its landscape lock. Belt to [HoldWindowInsetsListeners]' braces. + */ + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + window.decorView.requestApplyInsets() + } + override fun onResume() { super.onResume() startSc2MenuNav()