feat(audio-rooms): M8 polish + M3/M9 foreground service

M8a — presence event reflects mic-mute state:
- AudioRoomStage's `publishPresence` now passes the broadcaster's
  current mic state into the kind 10312 `muted` tag: `null` when not
  broadcasting (no mic to be muted on), explicit `true` / `false` while
  the speaker path is `Broadcasting`. Other clients can now render a
  mute indicator on our avatar.

M8b — auto-reconnect with capped exponential backoff:
- AudioRoomViewModel detects `NestsListenerState.Failed` and schedules a
  retry after 1s, 2s, 4s, ..., capped at 16s. Up to 3 attempts before
  the UI stays in Failed for a manual retry.
- User-initiated `connect()` and `disconnect()` reset the retry counter
  so manual recovery starts fresh.

M8c — iOS:
- Skipped: neither `:nestsClient` nor `:quic` declares an iOS target
  yet, so there's no iosMain source set to populate. When iOS lands,
  the audio capture/playback + transport actuals will need iOS impls
  (and the speaker UI will need an iOS shell).

M3 + M9 — foreground service:
- New `AudioRoomForegroundService` (foregroundServiceType
  `mediaPlayback|microphone`) anchors the process so audio keeps
  playing with the screen off. Holds a partial wake-lock + media-style
  notification; the notification's "Stop" action stops the service.
- Lifecycle wired in AudioRoomStage via:
  * LaunchedEffect(isConnected, isBroadcasting) on the listener +
    broadcast UI state — promotes to mediaPlayback+microphone type
    when broadcasting starts (Android 14+ split foreground-type
    permission requirement), falls back to mediaPlayback when only
    listening, stops entirely when listener drops.
  * DisposableEffect(Unit) for screen-exit cleanup.
- The service does NOT own the MoQ session / decoder / player — those
  remain in the VM. Screen-off works; "navigate away keeps audio" would
  require moving the audio stack into the service, which is a bigger
  refactor outside the audio-rooms completion plan's scope.
- Strings: 6 new `audio_room_notification_*` keys.
- Manifest: declares the service with `mediaPlayback|microphone`
  foreground type. RECORD_AUDIO + FOREGROUND_SERVICE_MICROPHONE were
  already declared.

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:quic:jvmTest :amethyst:compilePlayDebugKotlin` all green.
This commit is contained in:
Claude
2026-04-26 03:34:28 +00:00
parent 1c6d939d28
commit 0a45d1094f
5 changed files with 382 additions and 38 deletions
@@ -109,6 +109,12 @@ class AudioRoomViewModel(
private var requestedSpeakers: Set<String> = emptySet()
private var closed = false
// Auto-reconnect state — incremented every time we observe Failed and
// schedule a retry; reset to 0 on Connected or on a user-initiated
// connect()/disconnect() so manual recovery starts fresh.
private var autoRetryAttempts = 0
private var autoRetryJob: Job? = null
// Speaker / publisher path
private var speaker: NestsSpeaker? = null
private var broadcastHandle: BroadcastHandle? = null
@@ -133,6 +139,11 @@ class AudioRoomViewModel(
val current = _uiState.value.connection
if (current is ConnectionUiState.Connecting || current is ConnectionUiState.Connected) return
// User-initiated connect — clear any pending auto-retry timer + counter.
autoRetryJob?.cancel()
autoRetryJob = null
autoRetryAttempts = 0
_uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
connectJob =
@@ -264,12 +275,17 @@ class AudioRoomViewModel(
/** Tear down without finalizing the VM (e.g. user pressed Disconnect). */
fun disconnect() {
if (closed) return
autoRetryJob?.cancel()
autoRetryJob = null
autoRetryAttempts = 0
teardownBroadcast(BroadcastUiState.Idle)
teardown(targetState = ConnectionUiState.Idle)
}
override fun onCleared() {
closed = true
autoRetryJob?.cancel()
autoRetryJob = null
teardownBroadcast(BroadcastUiState.Idle)
teardown(targetState = ConnectionUiState.Closed)
super.onCleared()
@@ -323,13 +339,87 @@ class AudioRoomViewModel(
_uiState.update { ui ->
ui.copy(connection = state.toUiState(ui.connection))
}
if (state is NestsListenerState.Connected) {
reconcileSubscriptions()
when (state) {
is NestsListenerState.Connected -> {
autoRetryAttempts = 0
reconcileSubscriptions()
}
is NestsListenerState.Failed -> {
scheduleAutoRetry()
}
else -> { /* no extra side effect */ }
}
}
}
}
/**
* 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.
*/
private fun scheduleAutoRetry() {
if (closed) return
if (autoRetryJob?.isActive == true) return
if (autoRetryAttempts >= MAX_AUTO_RETRIES) return
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()
}
}
/**
* Internal connect that bypasses the auto-retry counter reset — used by
* [scheduleAutoRetry] so successive auto-retries continue to back off.
*/
private fun connectInternal() {
if (closed) return
val current = _uiState.value.connection
if (current is ConnectionUiState.Connecting || current is ConnectionUiState.Connected) return
_uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
connectJob =
viewModelScope.launch {
try {
val l =
connector.connect(
httpClient = httpClient,
transport = transport,
scope = viewModelScope,
serviceBase = serviceBase,
roomId = roomId,
signer = signer,
)
if (closed) {
runCatching { l.close() }
return@launch
}
listener = l
observeListenerState(l)
} catch (ce: CancellationException) {
throw ce
} catch (t: Throwable) {
_uiState.update {
it.copy(connection = ConnectionUiState.Failed(t.message ?: t::class.simpleName ?: "connect failed"))
}
scheduleAutoRetry()
}
}
}
private fun reconcileSubscriptions() {
val l = listener ?: return
if (_uiState.value.connection !is ConnectionUiState.Connected) return
@@ -588,6 +678,15 @@ sealed class BroadcastUiState {
*/
const val SPEAKING_TIMEOUT_MS: Long = 250L
/** Max number of auto-reconnect attempts after a Failed listener state. */
private const val MAX_AUTO_RETRIES = 3
/** First auto-retry backoff in ms; doubles each subsequent attempt. */
private const val INITIAL_RETRY_BACKOFF_MS = 1_000L
/** Cap on the auto-retry backoff so the wait stays human-acceptable. */
private const val MAX_RETRY_BACKOFF_MS = 16_000L
/**
* Indirection over the top-level `connectNestsListener` so tests can drive
* a fake [NestsListener] directly without standing up an HTTP fake +