From e79f78fc2807a1bbcd263f2679bbde93d1bf9a68 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 02:47:02 +0000 Subject: [PATCH] fix(audio-rooms): keep state alive across Reconnecting blips (audit walk #1, #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reliability bugs the user-walkthrough audit caught: 1. Foreground service flickered on every transport blip. The LaunchedEffect keyed on `isConnected = ui.connection is Connected`, so a Reconnecting state stopped the service mid-blip and restarted it on recovery. Beyond the audible dropout from losing the wake-lock, Android 14+ doesn't reliably let us re-promote to FOREGROUND_SERVICE_TYPE_MICROPHONE after losing it — risking a permanent broadcast-side regression after a single relay hiccup. Treat Reconnecting as "still live" for the service decision. 2. TalkRow disappeared mid-broadcast on transient listener disconnect. The speaker session is INDEPENDENT of the listener — the user could be broadcasting cleanly while the listener is Reconnecting, but our `if (ui.connection !is Connected) return` early-exited the entire row, so the user could neither mute their mic nor stop their broadcast until the listener recovered. Switch the gate to "user can act on broadcast state" — Connected listener OR an in-flight broadcast handle (Connecting/Broadcasting). Same logic applied to the PIP-entry gate so onUserLeaveHint during a transient blip doesn't lock the user out of PIP. --- .../room/AudioRoomActivityContent.kt | 25 +++++++++++++++---- .../audiorooms/room/AudioRoomFullScreen.kt | 11 +++++++- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt index 487f20d8f..d54ea0328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomActivityContent.kt @@ -245,7 +245,13 @@ private fun AudioRoomActivityBody( // Push mute + connected state up so the Activity can rebuild PIP // RemoteActions and gate `enterPictureInPictureMode` on Connected. LaunchedEffect(ui.isMuted) { onMuteState(ui.isMuted) } - val isConnectedNow = ui.connection is ConnectionUiState.Connected + // PIP entry gate — treat Reconnecting as "still in the room" so a + // mid-room network blip doesn't lock the user out of PIP. The + // listener's hot SubscribeHandle pump preserves audio across the + // cutover; PIP would otherwise un-arm and re-arm spuriously. + val isConnectedNow = + ui.connection is ConnectionUiState.Connected || + ui.connection is ConnectionUiState.Reconnecting LaunchedEffect(isConnectedNow) { onConnectedChange(isConnectedNow) } // Forward PIP-overlay mute taps into the VM. Per-Activity SharedFlow @@ -323,13 +329,22 @@ private fun AudioRoomActivityBody( // Foreground service lifecycle — the Activity is the lifecycle anchor; // the service exists for screen-locked / activity-stopped audio. + // Treat Reconnecting as "still live" so a transient transport blip + // doesn't drop the wake-lock + foreground type. On Android 14+ + // re-promoting to FOREGROUND_SERVICE_TYPE_MICROPHONE after losing + // it isn't always allowed; keeping the service running across the + // wrapper's recovery window is both gentler on the user (no audio + // dropout from the OS killing us) and safer against the platform + // permission model. val context = LocalContext.current - val isConnected = ui.connection is ConnectionUiState.Connected + val isLive = + ui.connection is ConnectionUiState.Connected || + ui.connection is ConnectionUiState.Reconnecting val isBroadcasting = ui.broadcast is BroadcastUiState.Broadcasting - LaunchedEffect(isConnected, isBroadcasting) { + LaunchedEffect(isLive, isBroadcasting) { when { - isConnected && isBroadcasting -> AudioRoomForegroundService.promoteToMicrophone(context) - isConnected -> AudioRoomForegroundService.startListening(context) + isLive && isBroadcasting -> AudioRoomForegroundService.promoteToMicrophone(context) + isLive -> AudioRoomForegroundService.startListening(context) else -> AudioRoomForegroundService.stop(context) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt index cd692f477..41a99ca97 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomFullScreen.kt @@ -368,7 +368,16 @@ private fun TalkRow( ui: AudioRoomUiState, speakerPubkeyHex: String, ) { - if (ui.connection !is ConnectionUiState.Connected) return + // Render whenever the user can act on broadcast state. The + // speaker session is independent of the listener: a transient + // listener Reconnecting must NOT hide the mic-mute / Stop + // controls if the broadcast is in flight, otherwise the user + // can't pause their own mic during a network blip. + val canAct = + ui.connection is ConnectionUiState.Connected || + ui.broadcast is BroadcastUiState.Broadcasting || + ui.broadcast is BroadcastUiState.Connecting + if (!canAct) return val context = LocalContext.current var permissionDenied by rememberSaveable { mutableStateOf(false) } val permissionLauncher =