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
// 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)
}
}
@@ -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 =