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)