fix nests demoted speakers stuck on stage and broadcasting

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.
This commit is contained in:
Vitor Pamplona
2026-05-13 15:40:57 -04:00
parent 20a2270434
commit 680aacf5b9
3 changed files with 63 additions and 3 deletions
@@ -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 {
@@ -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,
),
),
)
}
}
@@ -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)