From 0a45d1094f04bd7aca1c4162bcc4af601e72d0c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 03:34:28 +0000 Subject: [PATCH] feat(audio-rooms): M8 polish + M3/M9 foreground service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- amethyst/src/main/AndroidManifest.xml | 6 + .../audiorooms/AudioRoomForegroundService.kt | 205 ++++++++++++++++++ .../audiorooms/room/AudioRoomStage.kt | 100 ++++++--- amethyst/src/main/res/values/strings.xml | 6 + .../commons/viewmodels/AudioRoomViewModel.kt | 103 ++++++++- 5 files changed, 382 insertions(+), 38 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/audiorooms/AudioRoomForegroundService.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 9f41d69f2..a37046037 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -260,6 +260,12 @@ android:stopWithTask="false" android:exported="false" /> + + { + startForegroundWithType(includeMic = true) + } + + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + + else -> { + startForegroundWithType(includeMic = promoted) + } + } + if (wakeLock?.isHeld != true) { + wakeLock?.acquire(WAKE_LOCK_TIMEOUT_MS) + } + return START_STICKY + } + + private fun startForegroundWithType(includeMic: Boolean) { + promoted = includeMic + val notification = buildNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val type = + if (includeMic) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + } + startForeground(NOTIFICATION_ID, notification, type) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun buildNotification(): Notification { + val openIntent = + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val stopIntent = + PendingIntent.getService( + this, + 1, + Intent(this, AudioRoomForegroundService::class.java).apply { action = ACTION_STOP }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val title = + if (promoted) { + getString(R.string.audio_room_notification_broadcasting) + } else { + getString(R.string.audio_room_notification_listening) + } + + return NotificationCompat + .Builder(this, CHANNEL_ID) + .setContentTitle(title) + .setContentText(getString(R.string.audio_room_notification_text)) + .setSmallIcon(R.drawable.amethyst) + .setOngoing(true) + .setContentIntent(openIntent) + .addAction(0, getString(R.string.audio_room_notification_stop), stopIntent) + .setCategory(NotificationCompat.CATEGORY_CALL) + .build() + } + + private fun createNotificationChannel() { + val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (mgr.getNotificationChannel(CHANNEL_ID) == null) { + mgr.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + getString(R.string.audio_room_notification_channel), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = getString(R.string.audio_room_notification_channel_description) + setShowBadge(false) + }, + ) + } + } + + override fun onDestroy() { + wakeLock?.takeIf { it.isHeld }?.release() + wakeLock = null + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + companion object { + private const val CHANNEL_ID = "audio_room_foreground" + private const val NOTIFICATION_ID = 0xA0D10 + private const val ACTION_PROMOTE_TO_MIC = "com.vitorpamplona.amethyst.audio_room.PROMOTE_MIC" + private const val ACTION_STOP = "com.vitorpamplona.amethyst.audio_room.STOP" + private const val WAKE_LOCK_TIMEOUT_MS = 12L * 60 * 60 * 1000 // 12 hours hard cap + + /** Start the service in listener-only foreground mode. Idempotent. */ + fun startListening(context: Context) { + ContextCompat.startForegroundService( + context, + Intent(context, AudioRoomForegroundService::class.java), + ) + } + + /** + * Re-`startForeground` with mediaPlayback + microphone type while the + * user is broadcasting. Required by Android 14's split foreground-type + * permission model. + */ + fun promoteToMicrophone(context: Context) { + ContextCompat.startForegroundService( + context, + Intent(context, AudioRoomForegroundService::class.java).apply { + action = ACTION_PROMOTE_TO_MIC + }, + ) + } + + /** Stop and remove the foreground notification. Idempotent. */ + fun stop(context: Context) { + context.stopService(Intent(context, AudioRoomForegroundService::class.java)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt index 51ead34af..68635cd9c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActiviti import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState +import com.vitorpamplona.amethyst.service.audiorooms.AudioRoomForegroundService import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -95,20 +96,19 @@ import kotlinx.coroutines.launch * Clubhouse-style audio-room "stage" rendered in place of the video player when * the underlying activity is a NIP-53 kind 30312 [MeetingSpaceEvent]. * - * Responsibilities (M1 = listener-only): + * Responsibilities: * - Displays host / speaker / audience avatars parsed from the 30312 `p` tags. - * - Publishes kind 10312 presence on enter and every 30 s while composed. - * - Hand-raise toggle flips the `["hand","1"|"0"]` tag on that presence event - * so a host on any NIP-53 client can see the request and promote. - * - **Connect button** opens the listener-side audio pipeline (HTTP → - * WebTransport over QUIC → MoQ → Opus decode → AudioTrack). On Connected, - * auto-subscribes to every host's speaker track. - * - **Mute toggle** silences the local audio device without halting the - * network pipeline so unmute is instant. - * - * Speaker-side (mic capture + publish) is M5+ in the audio-rooms completion - * plan — `nestsClient/plans/2026-04-26-audio-rooms-completion.md`. Until then - * the presence event omits the `muted` tag (we have no mic to mute). + * Avatars get a primary-color ring while their MoQ track is delivering audio. + * - Publishes kind 10312 presence on enter and every 30 s while composed, + * reflecting the hand-raise + mic-mute state. + * - **Listener** path: Connect button → HTTP → WebTransport over QUIC → MoQ + * listener session → Opus decode → AudioTrack. Auto-subscribes to every + * host + speaker track. Mute toggle silences the local device without + * halting the network so unmute is instant. + * - **Speaker** path (only for users in the room's `p` tags as host or + * speaker): Talk button → RECORD_AUDIO permission → second MoQ session + * in publisher mode → AudioRecord → MediaCodec Opus encoder → MoQ + * OBJECT_DATAGRAM emission. Live indicator + mic-mute toggle. */ @Composable fun AudioRoomStage( @@ -144,25 +144,6 @@ private fun AudioRoomStageContent( val scope = rememberCoroutineScope() val account = accountViewModel.account - // Publish initial presence on enter and refresh every PRESENCE_REFRESH_MS while composed. - LaunchedEffect(event.address().toValue(), handRaised) { - publishPresence(account, event, handRaised) - while (isActive) { - delay(PRESENCE_REFRESH_MS) - publishPresence(account, event, handRaised) - } - } - - // Best-effort "leave" — re-publish a lowered-hand presence so peers see us - // drop sooner than the 30 s heartbeat would otherwise allow. - DisposableEffect(event.address().toValue()) { - onDispose { - scope.launch(Dispatchers.IO) { - runCatching { publishPresence(account, event, handRaised = false) } - } - } - } - val serviceBase = event.service() val roomId = event.address().dTag val audioAvailable = !serviceBase.isNullOrBlank() && roomId.isNotBlank() @@ -193,6 +174,52 @@ private fun AudioRoomStageContent( val ui = viewModel?.uiState?.collectAsState()?.value val speakingNow = ui?.speakingNow ?: persistentSetOf() + // Foreground-service lifecycle. The service is a process-anchor — it + // doesn't own the audio resources (those live in the VM) but its + // foreground notification + wake-lock keep audio playing with the + // screen off. Promotes to mediaPlayback+microphone type while the user + // broadcasts (Android 14+ split foreground-type permission model). + val context = LocalContext.current + val isConnected = ui?.connection is ConnectionUiState.Connected + val isBroadcasting = ui?.broadcast is BroadcastUiState.Broadcasting + LaunchedEffect(isConnected, isBroadcasting) { + when { + isConnected && isBroadcasting -> AudioRoomForegroundService.promoteToMicrophone(context) + isConnected -> AudioRoomForegroundService.startListening(context) + else -> AudioRoomForegroundService.stop(context) + } + } + DisposableEffect(Unit) { + onDispose { AudioRoomForegroundService.stop(context) } + } + // Mic state for the presence event: null when not broadcasting (we have + // no mic stream to be muted on), explicit true/false while live so other + // clients can render the mute indicator on our avatar. + val micMutedTag: Boolean? = + when (val b = ui?.broadcast) { + is BroadcastUiState.Broadcasting -> b.isMuted + else -> null + } + + // Publish initial presence on enter and refresh every PRESENCE_REFRESH_MS while composed. + LaunchedEffect(event.address().toValue(), handRaised, micMutedTag) { + publishPresence(account, event, handRaised, micMutedTag) + while (isActive) { + delay(PRESENCE_REFRESH_MS) + publishPresence(account, event, handRaised, micMutedTag) + } + } + + // Best-effort "leave" — re-publish a lowered-hand presence so peers see us + // drop sooner than the 30 s heartbeat would otherwise allow. + DisposableEffect(event.address().toValue()) { + onDispose { + scope.launch(Dispatchers.IO) { + runCatching { publishPresence(account, event, handRaised = false, micMuted = null) } + } + } + } + Card( modifier = Modifier.fillMaxWidth().padding(8.dp), shape = RoundedCornerShape(12.dp), @@ -530,16 +557,17 @@ private suspend fun publishPresence( account: com.vitorpamplona.amethyst.model.Account, event: MeetingSpaceEvent, handRaised: Boolean, + micMuted: Boolean?, ) { runCatching { account.signAndComputeBroadcast( MeetingRoomPresenceEvent.build( root = event, handRaised = handRaised, - // muted tag intentionally omitted — listener-only mute (M1) - // silences our speakers, not a mic we don't yet broadcast. - // Re-evaluate when M5 (publisher path) lands. - muted = null, + // null = not broadcasting (no mic stream); true/false reflect + // the speaker's current mic state so other clients can show a + // mute indicator on the avatar. + muted = micMuted, ), ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index efacb3dd5..1f54d9aea 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -512,6 +512,12 @@ Live Broadcast failed: %1$s Microphone access is required to talk in this room. + Audio rooms + Keeps audio playing while a room is open. + Audio room connected + Audio room — Live + Tap to return. + Stop Videos Articles Private Bookmarks diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt index c6c816196..06f50d90e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt @@ -109,6 +109,12 @@ class AudioRoomViewModel( private var requestedSpeakers: Set = 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 +