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:
+20
-3
@@ -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 {
|
||||
|
||||
+19
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+24
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user