From 680aacf5b9a5c5940f7afae7a384d1dcce279bf2 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 13 May 2026 15:40:57 -0400 Subject: [PATCH] fix nests demoted speakers stuck on stage and broadcasting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related bugs when a host removes a speaker from the stage on nostrnests by editing the kind-30312 to drop their p-tag: 1. NestRoomFilterAssembler didn't subscribe to the kind-30312 itself while in-room — only chat/presence/reactions (#a) and admin commands. Once joined, the room event was frozen on whatever version loaded the screen, so demotions never propagated. Added a per-relay filter on `kinds=[30312], authors=[host], #d=[dTag]`. 2. Even if the demotion did propagate, `BroadcastHandle` kept running. Only the manual Leave Stage button called `stopBroadcast()`. Added a LaunchedEffect that tears down the broadcast when the local user falls off `participantGrid.onStage` while still publishing — covers both demote-by-host and leave-stage-on-another-client. 3. The auto-stop reliably exercised a pre-existing NestForegroundService bug: when `startListening` was called to demote from mic+media to media-only, `intent.action == null` fell through to `else -> promoted`, keeping mic=true. The service then asked startForeground for FOREGROUND_SERVICE_TYPE_MICROPHONE after the mic had been released, threw SecurityException on Android 14+, runCatching swallowed it, stopSelf ran without startForeground → ForegroundServiceDidNotStartInTimeException. The explicit-demote branch now returns false; only intent==null (OS sticky-restart) preserves the prior promoted state. --- .../service/nests/NestForegroundService.kt | 23 +++++++++++++++--- .../datasource/NestRoomFilterAssembler.kt | 19 +++++++++++++++ .../room/activity/NestActivityContent.kt | 24 +++++++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt index aefe23703..cb6ac03f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/nests/NestForegroundService.kt @@ -310,10 +310,27 @@ class NestForegroundService : Service() { // startForegroundService MUST call startForeground within 5 s, on // every invocation (audit Android #8). Even the STOP path needs a // foreground state before stopForeground can demote it cleanly. + // + // Three startForegroundService entry points reach this code: + // - `startListening(context)` → intent != null, action == null + // The wrapper is in listener-only mode; the service must + // foreground with MEDIA_PLAYBACK type (no MICROPHONE). + // - `promoteToMicrophone(ctx)` → intent.action == ACTION_PROMOTE_TO_MIC + // - OS-restart of a START_STICKY service → intent == null + // Preserve whatever foreground type we last ran with. + // + // The explicit demote branch (`else -> false`) is load-bearing: + // when the user leaves the stage, `startListening` is called to + // demote, AudioRecord is being released, and asking the OS for + // FOREGROUND_SERVICE_TYPE_MICROPHONE here throws SecurityException + // on Android 14+. The runCatching below would then `stopSelf()` + // without ever calling `startForeground`, tripping + // ForegroundServiceDidNotStartInTimeException. val mic = - when (intent?.action) { - ACTION_PROMOTE_TO_MIC -> true - else -> promoted + when { + intent == null -> promoted + intent.action == ACTION_PROMOTE_TO_MIC -> true + else -> false } runCatching { startForegroundWithType(includeMic = mic) } .onFailure { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt index bb7db2368..5d2ba8643 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/datasource/NestRoomFilterAssembler.kt @@ -108,6 +108,25 @@ class NestRoomFilterSubAssembler( since = since?.get(relay)?.time, ), ), + // Live-update subscription for the room event itself. + // EventFinder loads it once when missing and stops; the + // discovery-side feeds (FilterLiveActivitiesBy*) don't + // run while the user is inside a room. So without this + // third filter, a kind-30312 edit on the host's end + // (remove-from-stage, role change, status flip) never + // reaches the in-room screen — the demoted speaker + // keeps seeing themselves on stage and broadcasting, + // because participantGrid is built from a frozen event. + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(MeetingSpaceEvent.KIND), + authors = listOf(key.note.address.pubKeyHex), + tags = mapOf("d" to listOf(key.note.address.dTag)), + since = since?.get(relay)?.time, + ), + ), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt index 5c5a95970..28a889ff0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/nests/room/activity/NestActivityContent.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -45,6 +46,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.AddressableNote +import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState import com.vitorpamplona.amethyst.commons.viewmodels.NestViewModel import com.vitorpamplona.amethyst.commons.viewmodels.buildParticipantGrid @@ -255,9 +257,31 @@ private fun NestActivityBody( remember(participantGrid) { participantGrid.onStage.map { it.pubkey }.toSet() } + val isOnStageMe = remember(onStageKeys, localPubkey) { localPubkey in onStageKeys } AutoConnectAndTrackSpeakers(viewModel, onStageKeys) + // Auto-stop broadcast if the local user falls off the stage while + // still publishing — covers two cases the manual Leave Stage button + // doesn't: + // 1. Host edits the kind-30312 to remove our `p`-tag (demotion). + // Without this effect, the broadcast keeps running until the + // user navigates away and the MoQ relay accepts the packets, + // so a demoted speaker can keep talking on nostrnests. + // 2. The user taps Leave Stage on another client. Their kind-10312 + // arrives with onstage=0, participantGrid drops them, we tear + // down here in mirror. + // Driven by participantGrid + presence (the same source as the + // StageGrid render), so it stays consistent with what the UI shows. + LaunchedEffect(isOnStageMe, ui.broadcast) { + if (!isOnStageMe && + (ui.broadcast is BroadcastUiState.Broadcasting || ui.broadcast == BroadcastUiState.Connecting) + ) { + viewModel.stopBroadcast() + viewModel.setOnStage(false) + } + } + // Single REQ per relay covering chat, presence, reactions, admin // commands. See NestRoomFilterAssembler for the filter shape. NestRoomFilterAssemblerSubscription(roomNote, accountViewModel)