fix(audio-rooms): keep state alive across Reconnecting blips (audit walk #1, #2)

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.
This commit is contained in:
Claude
2026-04-27 02:47:02 +00:00
parent 54584c213f
commit e79f78fc28
2 changed files with 30 additions and 6 deletions
@@ -245,7 +245,13 @@ private fun AudioRoomActivityBody(
// Push mute + connected state up so the Activity can rebuild PIP // Push mute + connected state up so the Activity can rebuild PIP
// RemoteActions and gate `enterPictureInPictureMode` on Connected. // RemoteActions and gate `enterPictureInPictureMode` on Connected.
LaunchedEffect(ui.isMuted) { onMuteState(ui.isMuted) } 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) } LaunchedEffect(isConnectedNow) { onConnectedChange(isConnectedNow) }
// Forward PIP-overlay mute taps into the VM. Per-Activity SharedFlow // 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; // Foreground service lifecycle — the Activity is the lifecycle anchor;
// the service exists for screen-locked / activity-stopped audio. // 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 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 val isBroadcasting = ui.broadcast is BroadcastUiState.Broadcasting
LaunchedEffect(isConnected, isBroadcasting) { LaunchedEffect(isLive, isBroadcasting) {
when { when {
isConnected && isBroadcasting -> AudioRoomForegroundService.promoteToMicrophone(context) isLive && isBroadcasting -> AudioRoomForegroundService.promoteToMicrophone(context)
isConnected -> AudioRoomForegroundService.startListening(context) isLive -> AudioRoomForegroundService.startListening(context)
else -> AudioRoomForegroundService.stop(context) else -> AudioRoomForegroundService.stop(context)
} }
} }
@@ -368,7 +368,16 @@ private fun TalkRow(
ui: AudioRoomUiState, ui: AudioRoomUiState,
speakerPubkeyHex: String, 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 val context = LocalContext.current
var permissionDenied by rememberSaveable { mutableStateOf(false) } var permissionDenied by rememberSaveable { mutableStateOf(false) }
val permissionLauncher = val permissionLauncher =