fix(audio-rooms): audit follow-up — MoQ HIGH/MED + VM concurrency + Android polish

Round 2 of the audit-driven cleanup. Lands every HIGH and most MED
findings from the protocol / ViewModel / Android lifecycle audits that
weren't fixed in the previous commit. The wire-format draft pinning
(audit MoQ #1, #8) is intentionally deferred until the M4 manual interop
pass against `nostrnests.com` reveals which draft revision the relay is
actually speaking.

MoQ session fixes:
- #5 UNANNOUNCE wire-write now happens BEFORE removing announces[ns],
  so an inbound SUBSCRIBE during the teardown window sees the namespace
  as withdrawn (sessionClosed=true → SUBSCRIBE_ERROR(TRACK_DOES_NOT_EXIST))
  instead of "namespace never existed".
- #4 dispatchControlMessage(AnnounceError) now distinguishes pre-OK and
  post-OK errors. Post-OK is a session-level kick: mark the handle
  closed, send UNANNOUNCE, then drop. Pre-OK still rolls back the
  optimistic announces[] insert as before.
- #6 close() now joins the cancelled control + datagram pumps before
  calling controlStream.finish(), so an in-flight SUBSCRIBE_OK /
  SUBSCRIBE_DONE / SUBSCRIBE_ERROR write can complete its
  writeMutex.withLock { ... } critical section. Previously cancellation
  could truncate a frame mid-flight and we'd send FIN over a corrupted
  stream.
- #9 pumps wrapped in try/catch that calls close(...) on unexpected
  exceptions, so a transport-died-mid-session no longer leaves the
  session thinking it's healthy with new subscribe/announce calls
  hanging on a dead peer.
- #10 TrackPublisher.send rolls back nextObjectId when every datagram
  fan-out fails (transport down). The audio-rooms NIP wants strictly
  contiguous object ids per group; a gap from a fully-failed send
  would trip strict subscribers.
- #13 DefaultNestsSpeaker.close drops `gate` before calling
  activeHandle.close() / session.close(). The teardown chain runs
  cancelAndJoin on the broadcaster + sends SUBSCRIBE_DONE per attached
  subscriber + joins MoQ pumps; holding the gate through all of that
  blocked any other concurrent API call on this speaker.

Resource lifecycle (audit MoQ #11/#12):
- AudioRoomBroadcaster.stop() now `cancelAndJoin`s the loop before
  releasing the encoder + closing the publisher. The loop's last
  encoder.encode/publisher.send no longer races
  encoder.release()/publisher.close() — both produced use-after-release
  on native MediaCodec on Android, the latter sent orphan
  OBJECT_DATAGRAMs to subscribers we'd just told SUBSCRIBE_DONE.
- AudioRoomPlayer.stop() promoted to `suspend` + cancelAndJoin for the
  same reason: decoder.release() ran while the decode loop was still
  inside MediaCodec.decode(...), undefined behaviour. Updated VM call
  sites (closeSubscription, teardown) to route both player.stop() and
  handle.unsubscribe() through one launched coroutine via the new
  `detach(): Pair<AudioRoomPlayer?, SubscribeHandle?>` shape.

ViewModel fixes:
- #4 auto-retry uses a single `retryPending: Boolean` flag instead of
  `Job.isActive`. Two scheduleAutoRetry calls could previously both
  pass `Job.isActive == false` (the launched body had just started)
  and stack a second retry on top of one already running.
- #7 setMicMuted updates the UI INSIDE the launched coroutine, after
  the suspending broadcastHandle.setMuted() returns. Previously the
  indicator could claim "muted" while audio was still on the wire if
  the handle's setMuted suspended on a gate.
- #8 connect() cancels the previous stateObserverJob before kicking
  off the new attempt, so a delayed Failed/Closed emission from the
  old listener can no longer clobber the fresh Connecting UI.
- #9 disconnect() clears requestedSpeakers, so a fresh connect() to a
  different room (or the same room after a long pause) doesn't reuse
  a stale speaker snapshot.
- #12 updateSpeakers filters out the user's own pubkey: subscribing
  to your own forwarded audio would echo through the local playback
  device whenever the broadcast track loops back from the relay.

Android lifecycle / PIP / service:
- #6 PIP aspect ratio flipped from 9:16 (portrait sliver) to 16:9
  (landscape) so the row of avatars under the title actually fits.
- #7 process-death recovery: when AudioRoomBridge is empty (previous
  process's AccountViewModel is gone), redirect to MainActivity
  before finish() so the user lands somewhere meaningful instead of
  a black-flash.
- #8 AudioRoomForegroundService.onStartCommand always calls
  startForeground first, on every invocation including ACTION_STOP.
  startForegroundService's 5-second contract requires it; previously
  the STOP path skipped it. startForeground itself wrapped in
  runCatching so a foreground-not-allowed exception bails cleanly
  rather than leaking the wake-lock.
- #10 wake-lock timeout reduced 12 h → 4 h. Stuck connections that
  fail to detect a network drop no longer hold the device awake for
  half a day.
- #11 presence-event spam fix: split the publish loop into a
  heartbeat keyed only on (address, handRaised) and a separate
  debounced state-change publisher keyed on micMutedTag. Every mute
  toggle previously triggered a full sign + publish + relay round
  trip; now we coalesce within a 500 ms window.
- #12 final "leaving" presence routed through GlobalScope.launch
  instead of rememberCoroutineScope (which is cancelled on dispose,
  so the leave event almost never reached the relay).
- #14 RECORD_AUDIO denial recovery: when the user has tapped "Don't
  ask again", the launcher silently returns false. New "Open
  settings" button deep-links to the app's settings page so the user
  can re-grant the permission and try again.
- #15 setPictureInPictureParams now updates outside PIP too, so the
  next entry shows the correct mute-state icon without an extra flip.
- #16 onNewIntent override: a second Join tap for a different room
  finishes the current Activity and starts a fresh one with the new
  extras, instead of singleTask silently keeping the old room
  running.

Verified: spotlessApply clean; :commons:jvmTest, :nestsClient:jvmTest
(80 tests), :amethyst:compilePlayDebugKotlin all green.

Audit findings still deferred (all documented inline / in this commit):
- MoQ #1, #8: wire-format draft pinning (draft-17 vs draft-11). Needs
  the M4 manual interop pass to confirm what nests is actually speaking.
- VM #6: confined map mutation under a single dispatcher. Current
  setup (Dispatchers.Main via setMain in tests, viewModelScope in
  prod) is functionally fine; full belt-and-suspenders confining is a
  separate concurrency review.
- VM #10: test coverage gaps for retry + speaker reconcile cycle +
  setMicMuted no-handle case + server-initiated Closed leaves stale
  state. Each is a dedicated test.
- Android #18: startListening / promoteToMicrophone race. Mitigation
  (always declaring microphone foreground type) requires unconditional
  RECORD_AUDIO grant which listener-only users won't have.
This commit is contained in:
Claude
2026-04-26 12:04:18 +00:00
parent f0b27654ba
commit 0b1ec52f79
10 changed files with 323 additions and 76 deletions
@@ -123,16 +123,28 @@ class AudioRoomViewModel(
private var autoRetryAttempts = 0
private var autoRetryJob: Job? = null
// True between scheduleAutoRetry() and the launched coroutine's
// `finally`. Single source of truth — Job.isActive flips to false
// the moment the launched body starts running, which created a
// window where two retries could both pass the gate.
private var retryPending = false
// Speaker / publisher path
private var speaker: NestsSpeaker? = null
private var broadcastHandle: BroadcastHandle? = null
private var speakerStateJob: Job? = null
private var speakerConnectJob: Job? = null
/** Push the latest known speaker set from the room event. */
/**
* Push the latest known speaker set from the room event. The user's
* own pubkey (when broadcasting) is filtered out so we don't subscribe
* to our own forwarded audio — that would echo through the local
* playback device whenever our broadcast track loops back from the relay.
*/
fun updateSpeakers(speakerPubkeys: Set<String>) {
if (closed) return
requestedSpeakers = speakerPubkeys
val selfHex = signer.pubKey
requestedSpeakers = if (selfHex.isEmpty()) speakerPubkeys else speakerPubkeys - selfHex
if (_uiState.value.connection is ConnectionUiState.Connected) {
reconcileSubscriptions()
}
@@ -151,6 +163,12 @@ class AudioRoomViewModel(
autoRetryJob?.cancel()
autoRetryJob = null
autoRetryAttempts = 0
retryPending = false
// Cancel any previous state observer too — otherwise its delayed
// emissions can clobber the fresh Connecting UI we set below
// (audit VM #8).
stateObserverJob?.cancel()
stateObserverJob = null
_uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
@@ -258,19 +276,31 @@ class AudioRoomViewModel(
}
}
/** Toggle the speaker-side mic mute. Cheap; the network keeps running. */
/**
* Toggle the speaker-side mic mute. Cheap; the network keeps running.
*
* UI flips AFTER the suspending `broadcastHandle.setMuted(...)` returns
* so the indicator never claims muted while audio is still flowing
* (audit VM #7). We accept a small UI latency in exchange for an
* accurate state machine.
*/
fun setMicMuted(muted: Boolean) {
if (closed) return
val handle = broadcastHandle ?: return
viewModelScope.launch {
broadcastHandle?.runCatching { setMuted(muted) }
}
_uiState.update {
val current = it.broadcast
if (current is BroadcastUiState.Broadcasting) {
it.copy(broadcast = current.copy(isMuted = muted))
} else {
it
}
handle
.runCatching { setMuted(muted) }
.onSuccess {
if (closed) return@onSuccess
_uiState.update {
val current = it.broadcast
if (current is BroadcastUiState.Broadcasting) {
it.copy(broadcast = current.copy(isMuted = muted))
} else {
it
}
}
}
}
}
@@ -286,6 +316,11 @@ class AudioRoomViewModel(
autoRetryJob?.cancel()
autoRetryJob = null
autoRetryAttempts = 0
retryPending = false
// Forget the requested speaker set so a fresh connect() to a
// different room (or same room after a long pause) doesn't reuse
// a stale snapshot that may no longer be on stage.
requestedSpeakers = emptySet()
teardownBroadcast(BroadcastUiState.Idle, finalCleanup = false)
teardown(targetState = ConnectionUiState.Idle, finalCleanup = false)
}
@@ -383,24 +418,34 @@ class AudioRoomViewModel(
* Auto-reconnect on listener Failed with capped exponential backoff.
* The user can still tap Connect manually at any time; that resets the
* retry counter via the `connect()` path.
*
* `retryPending` is the single source of truth — `Job.isActive` returns
* false the moment a coroutine starts running its body, which created a
* window where two scheduleAutoRetry calls could both pass the gate
* (audit VM #4).
*/
private fun scheduleAutoRetry() {
if (closed) return
if (autoRetryJob?.isActive == true) return
if (retryPending) return
if (autoRetryAttempts >= MAX_AUTO_RETRIES) return
retryPending = true
val attempt = autoRetryAttempts
autoRetryAttempts = attempt + 1
val backoffMs = minOf(MAX_RETRY_BACKOFF_MS, INITIAL_RETRY_BACKOFF_MS shl attempt)
autoRetryJob =
viewModelScope.launch {
delay(backoffMs)
if (closed) return@launch
if (_uiState.value.connection !is ConnectionUiState.Failed) return@launch
// Drop the previous listener cleanly before starting a new
// attempt. Reset state to Idle so the connect() guard passes.
teardown(targetState = ConnectionUiState.Idle)
connectInternal()
try {
delay(backoffMs)
if (closed) return@launch
if (_uiState.value.connection !is ConnectionUiState.Failed) return@launch
// Drop the previous listener cleanly before starting a new
// attempt. Reset state to Idle so the connect() guard passes.
teardown(targetState = ConnectionUiState.Idle)
connectInternal()
} finally {
retryPending = false
}
}
}
@@ -469,17 +514,21 @@ class AudioRoomViewModel(
}
/**
* Stop the per-speaker player synchronously (releases the audio device
* immediately) and fire-and-forget the MoQ UNSUBSCRIBE on the VM scope.
* Stop the per-speaker player + fire-and-forget UNSUBSCRIBE on the VM
* scope. Both `AudioRoomPlayer.stop()` and `SubscribeHandle.unsubscribe()`
* are suspend, so we route them through one coroutine instead of two.
*/
private fun closeSubscription(slot: ActiveSubscription) {
val handle = slot.detach()
val (roomPlayer, handle) = slot.detach()
speakingExpiryJobs.remove(slot.pubkey)?.cancel()
if (_uiState.value.speakingNow.contains(slot.pubkey)) {
_uiState.update { it.copy(speakingNow = (it.speakingNow - slot.pubkey).toPersistentSet()) }
}
if (handle != null) {
viewModelScope.launch { runCatching { handle.unsubscribe() } }
if (roomPlayer != null || handle != null) {
viewModelScope.launch {
roomPlayer?.runCatching { stop() }
handle?.runCatching { unsubscribe() }
}
}
}
@@ -532,22 +581,29 @@ class AudioRoomViewModel(
connectJob = null
stateObserverJob?.cancel()
stateObserverJob = null
// Stop players synchronously — unsubscribe happens implicitly when
// the listener.close() below tears down the MoQ session.
activeSubscriptions.values.forEach { it.detach() }
// Detach + suspend-stop each player on the cleanup scope. The
// listener.close() below tears down the MoQ session and drops
// every active subscription, so we don't need to call
// unsubscribe() per-handle here — but we DO need to await each
// AudioRoomPlayer.stop() so the native MediaCodec / AudioTrack
// is released after its decode loop has unwound.
val scope = if (finalCleanup) cleanupScope else viewModelScope
val players = activeSubscriptions.values.map { it.detach().first }
activeSubscriptions.clear()
speakingExpiryJobs.values.forEach { it.cancel() }
speakingExpiryJobs.clear()
if (players.isNotEmpty()) {
scope.launch {
players.forEach { p -> p?.runCatching { stop() } }
}
}
val l = listener
listener = null
if (l != null) {
// Listener.close() sends MoQ control frames (UNSUBSCRIBE etc.)
// before the QUIC transport drops. User-driven disconnect uses
// the still-alive viewModelScope; onCleared has already had its
// viewModelScope cancelled, so it routes through cleanupScope
// (a process-lived scope) so the peer sees a clean teardown
// rather than a hard QUIC drop.
val scope = if (finalCleanup) cleanupScope else viewModelScope
// before the QUIC transport drops. Same scope rule as the
// player stop above: viewModelScope when alive, cleanupScope
// for onCleared so the wire teardown survives.
scope.launch { runCatching { l.close() } }
}
_uiState.update {
@@ -615,18 +671,20 @@ class AudioRoomViewModel(
}
/**
* Stop the player + decoder synchronously and return the
* [SubscribeHandle] (if any) so the caller can fire-and-forget
* UNSUBSCRIBE on its own coroutine scope.
* Hand the player + handle back to the caller's coroutine scope —
* `AudioRoomPlayer.stop()` and `SubscribeHandle.unsubscribe()` are
* both suspend, and the caller has the right scope to await them
* (so native MediaCodec/AudioTrack release runs after the decode
* loop has unwound, per audit MoQ #11/#12).
*/
fun detach(): SubscribeHandle? {
fun detach(): Pair<AudioRoomPlayer?, SubscribeHandle?> {
isPlaying = false
roomPlayer?.let { runCatching { it.stop() } }
val p = roomPlayer
val h = handle
roomPlayer = null
handle = null
player = null
return h
return p to h
}
companion object {