diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8fe883a1e..2ac3fce84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -124,6 +124,7 @@ private object PrefKeys { const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList" + const val CALLS_ENABLED = "calls_enabled" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later @@ -391,6 +392,7 @@ object LocalPreferences { putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) + putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) // migrating from previous design remove(PrefKeys.USE_PROXY) @@ -494,6 +496,7 @@ object LocalPreferences { val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) + val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) @@ -651,6 +654,7 @@ object LocalPreferences { viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()), pendingAttestations = MutableStateFlow(pendingAttestations.await()), backupNipA3PaymentTargets = latestPaymentTargets.await(), + callsEnabled = MutableStateFlow(callsEnabled), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index f6b681b61..924b439b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -203,6 +203,7 @@ class AccountSettings( var callTurnServers: List = emptyList(), var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, + val callsEnabled: MutableStateFlow = MutableStateFlow(true), ) : EphemeralChatRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) @@ -942,6 +943,13 @@ class AccountSettings( callMaxBitrateBps = bitrate saveAccountSettings() } + + fun changeCallsEnabled(enabled: Boolean) { + if (callsEnabled.value != enabled) { + callsEnabled.tryEmit(enabled) + saveAccountSettings() + } + } } @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 6132fed28..50913d770 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -212,6 +212,9 @@ class CallController( val callId = UUID.randomUUID().toString() _errorMessage.value = null + // A new call session begins — arm cleanup() so it will run again + // for this session's Ended transition. + cleanedUp.set(false) applyCallSettings() try { withContext(Dispatchers.IO) { mediaManager.initialize(callType) } @@ -258,6 +261,9 @@ class CallController( scope.launch { _errorMessage.value = null + // A new call session begins — arm cleanup() so it will run again + // for this session's Ended transition. + cleanedUp.set(false) applyCallSettings() try { withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) } @@ -304,8 +310,28 @@ class CallController( } AnswerRouteAction.NO_SESSION -> { - Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee" } - onNewPeerInGroupCall(peerPubKey) + // Unknown peer answering the current call. Two cases: + // + // 1. We are in Connected state — another participant invited a + // new peer mid-call and the invitee is broadcasting their + // acceptance to us. The invitee stays passive, so we MUST + // unconditionally initiate a mesh offer to them. The + // lower-pubkey tiebreaker does NOT apply here because only + // one side (the existing Connected callee) reacts to the + // broadcast answer. + // + // 2. We are still in Connecting state — both callees are + // handshaking in parallel during an initial group call and + // are observing each other's answers. Use the lower-pubkey + // tiebreaker via onNewPeerInGroupCall() to avoid glare, + // since the symmetric peer will apply the same rule. + if (callManager.state.value is CallState.Connected) { + Log.d(TAG) { "Mid-call invite: ${peerPubKey.take(8)} joined — initiating mesh offer" } + scope.launch { createAndOfferToPeer(peerPubKey) } + } else { + Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee" } + onNewPeerInGroupCall(peerPubKey) + } } AnswerRouteAction.IGNORED_WRONG_STATE -> { @@ -612,6 +638,17 @@ class CallController( // ---- Cleanup ---- + /** + * Releases all WebRTC, audio, and foreground-service resources for the + * current call session. + * + * Idempotent within a call session: calling this multiple times in a row + * (e.g. once from the Ended state collector and once from [CallActivity]'s + * onDestroy safety net) executes the body at most once. The guard is + * re-armed when a new call starts via [initiateGroupCall] or + * [acceptIncomingCall], so subsequent call sessions still clean up + * correctly. + */ fun cleanup() { if (!cleanedUp.compareAndSet(false, true)) return unregisterNetworkCallback() @@ -643,7 +680,10 @@ class CallController( videoPausedByProximity = false videoSenders.clear() pendingRenegotiation.clear() - cleanedUp.set(false) + // NOTE: cleanedUp intentionally stays true here so that a second, + // sequential cleanup() in the same call session is a no-op. The flag + // is re-armed in initiateGroupCall() / acceptIncomingCall() when a new + // call session begins. } // ---- Foreground service ---- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 3a1fbb8ae..e6560b7bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -202,28 +202,40 @@ class CallActivity : AppCompatActivity() { if (wasInPipMode && !isInPictureInPictureMode) { hangupInitiated = true val manager = CallSessionBridge.callManager + val controller = CallSessionBridge.callController val state = manager?.state?.value if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) { + // Publish the hangup signaling event on a detached scope so + // it survives activity teardown. CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { manager.hangup() - finishAndRemoveTask() } - } else { - finishAndRemoveTask() } + // Synchronously release all WebRTC/audio resources before the + // activity is torn down so we don't leak a PeerConnection, + // camera capturer, wake lock, or audio mode change. cleanup() is + // idempotent within a call session, so the state-collector path + // triggered by hangup() above will be a no-op. + controller?.cleanup() + finishAndRemoveTask() } } override fun onDestroy() { unregisterPipReceiver() + val manager = CallSessionBridge.callManager + val controller = CallSessionBridge.callController + // Safety net: if the Activity is destroyed while a call is still - // ringing/offering, ensure the call is hung up so audio stops. - // Skip if onStop already initiated the hangup to avoid double signaling. - if (!hangupInitiated) { - val manager = CallSessionBridge.callManager - when (manager?.state?.value) { + // ringing/offering, publish the reject/hangup signaling so the other + // side stops ringing. Skip if onStop already initiated the hangup to + // avoid double signaling. + if (!hangupInitiated && manager != null) { + when (manager.state.value) { is CallState.IncomingCall -> { + // Publish on a detached scope so the reject event goes out + // even after the activity is gone. CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { manager.rejectCall() } @@ -242,6 +254,15 @@ class CallActivity : AppCompatActivity() { } } + // Ultimate safety net: synchronously release all WebRTC/audio + // resources before the Activity reference goes away. The normal + // Ended → state-collector → cleanup() path runs asynchronously on + // viewModelScope and is not guaranteed to finish before this method + // returns. CallController.cleanup() is idempotent within a call + // session, so calling it here in addition to the state-collector path + // is safe. + controller?.cleanup() + super.onDestroy() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt index c606bd60f..8e64d01ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt @@ -48,11 +48,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes import org.webrtc.RendererCommon import org.webrtc.SurfaceViewRenderer import org.webrtc.VideoTrack @@ -104,6 +106,7 @@ fun VideoRenderer( @Composable fun PeerVideoGrid( peerPubKeys: Set, + pendingPeerPubKeys: Set, remoteVideoTracks: Map, activePeerVideos: Set, eglBase: org.webrtc.EglBase?, @@ -115,7 +118,8 @@ fun PeerVideoGrid( if (peers.size == 1) { val peerKey = peers[0] val track = remoteVideoTracks[peerKey] - if (track != null && peerKey in activePeerVideos) { + val isPending = peerKey in pendingPeerPubKeys + if (track != null && peerKey in activePeerVideos && !isPending) { VideoRenderer( videoTrack = track, eglBase = eglBase, @@ -126,6 +130,7 @@ fun PeerVideoGrid( PeerAvatarCell( peerPubKey = peerKey, accountViewModel = accountViewModel, + statusText = if (isPending) stringRes(R.string.call_calling) else null, modifier = modifier, ) } @@ -143,18 +148,21 @@ fun PeerVideoGrid( ) { row.forEach { peerKey -> val track = remoteVideoTracks[peerKey] - if (track != null && peerKey in activePeerVideos) { + val isPending = peerKey in pendingPeerPubKeys + val cellModifier = Modifier.weight(1f).fillMaxHeight() + if (track != null && peerKey in activePeerVideos && !isPending) { VideoRenderer( videoTrack = track, eglBase = eglBase, - modifier = Modifier.weight(1f).fillMaxHeight(), + modifier = cellModifier, mirror = false, ) } else { PeerAvatarCell( peerPubKey = peerKey, accountViewModel = accountViewModel, - modifier = Modifier.weight(1f).fillMaxHeight(), + statusText = if (isPending) stringRes(R.string.call_calling) else null, + modifier = cellModifier, ) } } @@ -172,6 +180,7 @@ fun PeerAvatarCell( peerPubKey: String, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, + statusText: String? = null, ) { Box( modifier = modifier.background(Color.DarkGray), @@ -197,6 +206,14 @@ fun PeerAvatarCell( ) } } + if (statusText != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = statusText, + color = Color.White.copy(alpha = 0.7f), + fontSize = 13.sp, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt index 700ddab1e..69defdf4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt @@ -145,6 +145,7 @@ fun ConnectedCallUI( if (hasActiveVideo) { PeerVideoGrid( peerPubKeys = otherMembers, + pendingPeerPubKeys = state.pendingPeerPubKeys, remoteVideoTracks = remoteVideoTracks, activePeerVideos = activePeerVideos, eglBase = callController?.getEglBase(), @@ -179,35 +180,27 @@ fun ConnectedCallUI( .windowInsetsPadding(WindowInsets.statusBars) .padding(top = 16.dp), ) - - if (state.pendingPeerPubKeys.isNotEmpty()) { - Text( - text = stringRes(R.string.call_waiting_for_others), - color = Color.White.copy(alpha = 0.5f), - fontSize = 13.sp, - modifier = - Modifier - .align(Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = 38.dp), - ) - } } else { + // Voice-only path: render the same peer grid so each pending + // callee shows their individual "Calling…" status rather than a + // single shared banner. Tracks and active-video sets are empty + // here, so every cell falls through to PeerAvatarCell. + val emptyTracks = remember { emptyMap() } + val emptyActive = remember { emptySet() } + Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - GroupCallPictures( + PeerVideoGrid( peerPubKeys = otherMembers, - size = 120.dp, + pendingPeerPubKeys = state.pendingPeerPubKeys, + remoteVideoTracks = emptyTracks, + activePeerVideos = emptyActive, + eglBase = null, accountViewModel = accountViewModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - GroupCallNames( - peerPubKeys = otherMembers, - accountViewModel = accountViewModel, - textColor = Color.White, + modifier = Modifier.weight(1f).fillMaxWidth(), ) Spacer(modifier = Modifier.height(8.dp)) Text( @@ -215,14 +208,7 @@ fun ConnectedCallUI( color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp, ) - if (state.pendingPeerPubKeys.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringRes(R.string.call_waiting_for_others), - color = Color.White.copy(alpha = 0.5f), - fontSize = 13.sp, - ) - } + Spacer(modifier = Modifier.height(24.dp)) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index af27793ee..8cb6ebac8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -199,6 +199,7 @@ class AccountViewModel( account.publishCallSignaling(wrap) } }, + isCallsEnabled = { account.settings.callsEnabled.value }, ) var callController: CallController? = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 397099f7c..f320c8341 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.service.call.CallSessionBridge @@ -49,7 +51,9 @@ fun ChatroomScreen( nav: INav, ) { val context = LocalContext.current - val isCallSupported = roomId.users.size <= 5 + val callsEnabled by accountViewModel.account.settings.callsEnabled + .collectAsState() + val isCallSupported = roomId.users.size <= 5 && callsEnabled val startVoiceCall = rememberCallWithPermission(context) { CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt index a8b638f3c..4d8fa3bb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt @@ -42,8 +42,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -85,6 +87,22 @@ fun CallSettingsScreen( @Composable private fun CallSettingsContent(accountViewModel: AccountViewModel) { val settings = accountViewModel.account.settings + val callsEnabled by settings.callsEnabled.collectAsState() + + EnableCallsSection( + enabled = callsEnabled, + onEnabledChanged = { settings.changeCallsEnabled(it) }, + ) + + if (!callsEnabled) { + // When calls are disabled the remaining settings (video quality, + // TURN servers, etc.) have no effect, so hide them to keep the + // screen focused on the single meaningful toggle. + Spacer(modifier = Modifier.height(16.dp)) + return + } + + HorizontalDivider(thickness = 4.dp, modifier = Modifier.padding(vertical = 8.dp)) SectionHeader(stringRes(R.string.call_settings_video_quality)) VideoResolutionSection( @@ -120,6 +138,39 @@ private fun CallSettingsContent(accountViewModel: AccountViewModel) { Spacer(modifier = Modifier.height(16.dp)) } +@Composable +private fun EnableCallsSection( + enabled: Boolean, + onEnabledChanged: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.call_settings_enable_calls), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringRes(R.string.call_settings_enable_calls_description), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = enabled, + onCheckedChange = onEnabledChanged, + ) + } +} + @Composable private fun SectionHeader(title: String) { Text( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b43403e23..4fffe2ae2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -815,6 +815,8 @@ Failed to accept call Failed to create call session Call Settings + Enable voice and video calls + When disabled, call buttons are hidden from chat screens and all incoming calls are silently ignored. Video Quality Max Video Bitrate TURN / STUN Servers diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 5b6ab8758..44bbe8d96 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -49,6 +49,15 @@ class CallManager( private val scope: CoroutineScope, private val isFollowing: (HexKey) -> Boolean, private val publishEvent: (EphemeralGiftWrapEvent) -> Unit, + /** + * Whether the user has enabled calls in Settings. When false, all + * incoming [CallOfferEvent]s are silently ignored so the device never + * rings and no `IncomingCall` state is entered. Signaling for calls that + * are already in progress is still processed so cleanup can complete. + * Defaults to `true` (enabled) so existing callers and tests keep their + * current behavior. + */ + private val isCallsEnabled: () -> Boolean = { true }, ) { private val factory = WebRtcCallFactory() @@ -79,6 +88,14 @@ class CallManager( private var resetJob: Job? = null private val processedEventIds = LinkedHashSet() + /** Per-peer invite timeout jobs. A separate 30-second timer is scheduled + * for each peer we are waiting on (initial group-call offerees and + * mid-call invitees) so that slow/unavailable peers can be dropped + * individually without affecting the rest of the call. The timer is + * cancelled when the peer answers, rejects, or hangs up — or when the + * whole call ends. */ + private val perPeerTimeoutJobs = mutableMapOf() + /** Call IDs for which we have seen a hangup, reject, or answer-elsewhere * signal. Checked before transitioning to [CallState.IncomingCall] so * that stale offer events replayed by relays after an app restart do not @@ -95,7 +112,8 @@ class CallManager( private val discoveredCalleePeers = mutableSetOf() companion object { - const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout + const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout (callee side) + const val PEER_INVITE_TIMEOUT_MS = 30_000L // 30 seconds per-peer invite timeout (caller side) const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this const val MAX_PROCESSED_EVENT_IDS = 2_000 // cap dedup set to prevent unbounded growth @@ -140,7 +158,8 @@ class CallManager( callType: CallType, ) = stateMutex.withLock { _state.value = CallState.Offering(callId, calleePubKeys, callType) - startTimeout(callId) + cancelAllPeerTimeouts() + calleePubKeys.forEach { schedulePeerTimeout(it, callId) } } /** @@ -201,10 +220,11 @@ class CallManager( val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer) stateMutex.withLock { _state.value = CallState.Offering(callId, setOf(calleePubKey), callType) - startTimeout(callId) + cancelAllPeerTimeouts() + schedulePeerTimeout(calleePubKey, callId) } publishEvent(result.wrap) - Log.d("CallManager") { "initiateCall: offer published, timeout started" } + Log.d("CallManager") { "initiateCall: offer published, per-peer timeout started" } } // ---- Incoming call handling ---- @@ -216,6 +236,15 @@ class CallManager( Log.d("CallManager") { "onIncomingCallEvent: from=${callerPubKey.take(8)}, callId=$callId, type=$callType, sdpOfferLength=${event.sdpOffer().length}" } + // User disabled calls in Settings — silently ignore new incoming + // offers so the device does not ring. Mid-call signaling for calls + // that are already in progress is still processed by the other + // branches in onSignalingEvent so cleanup can complete normally. + if (!isCallsEnabled()) { + Log.d("CallManager") { "onIncomingCallEvent: calls disabled in settings — ignoring" } + return + } + if (!isFollowing(callerPubKey)) { Log.d("CallManager") { "onIncomingCallEvent: caller not followed — ignoring" } return @@ -348,19 +377,35 @@ class CallManager( current.callType, pendingPeerPubKeys = pending, ) - cancelTimeout() + // The answered peer no longer needs its invite timer. Peers + // still in `pending` keep theirs (scheduled in beginOffering). + cancelPeerTimeout(answeringPeer) Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" } onAnswerReceived?.invoke(event) } is CallState.Connecting -> { if (callId != current.callId) return - if (answeringPeer in current.pendingPeerPubKeys) { - _state.value = - current.copy( - peerPubKeys = current.peerPubKeys + answeringPeer, - pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, - ) + when { + answeringPeer in current.pendingPeerPubKeys -> { + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + cancelPeerTimeout(answeringPeer) + } + + answeringPeer !in current.peerPubKeys -> { + // Mid-call join while we're still handshaking: another + // participant invited a new peer and that peer + // broadcast their acceptance to us. Expand our group + // membership so the UI reflects the new peer. + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + ) + } } // Forward to CallController — it routes to the correct PeerSession // and internally triggers callee-to-callee mesh setup if needed. @@ -378,12 +423,28 @@ class CallManager( is CallState.Connected -> { if (callId != current.callId) return - if (answeringPeer in current.pendingPeerPubKeys) { - _state.value = - current.copy( - peerPubKeys = current.peerPubKeys + answeringPeer, - pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, - ) + when { + answeringPeer in current.pendingPeerPubKeys -> { + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + cancelPeerTimeout(answeringPeer) + } + + answeringPeer !in current.peerPubKeys -> { + // Mid-call join: another participant invited a new + // peer and that peer broadcast their acceptance to us. + // Expand our group membership so the UI reflects the + // new peer. CallController will unconditionally + // initiate a mesh offer to them (the invitee stays + // passive). + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + ) + } } // Forward to CallController — it routes to the correct PeerSession // and internally triggers callee-to-callee mesh setup if needed. @@ -405,6 +466,7 @@ class CallManager( when (current) { is CallState.Offering -> { if (callId != current.callId) return + cancelPeerTimeout(rejectingPeer) val remaining = current.peerPubKeys - rejectingPeer if (remaining.isEmpty()) { transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED) @@ -416,6 +478,7 @@ class CallManager( is CallState.Connecting -> { if (callId != current.callId) return + cancelPeerTimeout(rejectingPeer) _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) onPeerLeft?.invoke(rejectingPeer) @@ -423,6 +486,7 @@ class CallManager( is CallState.Connected -> { if (callId != current.callId) return + cancelPeerTimeout(rejectingPeer) _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) onPeerLeft?.invoke(rejectingPeer) @@ -539,6 +603,10 @@ class CallManager( } } + // Start the per-peer invite timer. If the invitee does not answer + // within PEER_INVITE_TIMEOUT_MS, they are dropped from the call. + schedulePeerTimeout(peerPubKey, callId) + val allMembers = existingMembers + peerPubKey + signer.pubKey val result = factory.createCallOffer(sdpOffer, peerPubKey, allMembers, callId, callType, signer) publishEvent(result.wrap) @@ -588,6 +656,7 @@ class CallManager( when (current) { is CallState.Connected -> { if (callId != current.callId) return + cancelPeerTimeout(leavingPeer) val connectedRemaining = current.peerPubKeys - leavingPeer val pendingRemaining = current.pendingPeerPubKeys - leavingPeer if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { @@ -606,6 +675,7 @@ class CallManager( is CallState.Connecting -> { if (callId != current.callId) return + cancelPeerTimeout(leavingPeer) val connectedRemaining = current.peerPubKeys - leavingPeer val pendingRemaining = current.pendingPeerPubKeys - leavingPeer if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { @@ -624,6 +694,7 @@ class CallManager( is CallState.Offering -> { if (callId != current.callId) return + cancelPeerTimeout(leavingPeer) val remaining = current.peerPubKeys - leavingPeer if (remaining.isEmpty()) { transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP) @@ -735,6 +806,7 @@ class CallManager( fun reset() { _state.value = CallState.Idle cancelTimeout() + cancelAllPeerTimeouts() resetJob?.cancel() resetJob = null processedEventIds.clear() @@ -752,6 +824,7 @@ class CallManager( discoveredCalleePeers.clear() _state.value = CallState.Ended(callId, peerPubKeys, reason) cancelTimeout() + cancelAllPeerTimeouts() resetJob?.cancel() resetJob = scope.launch { @@ -763,6 +836,118 @@ class CallManager( } } + // ---- Per-peer invite timeout ---- + + /** + * Starts a 30-second timer for [peerPubKey]. If the peer has not answered + * by the time it fires, [handlePeerTimeout] drops them from the current + * group call and publishes a CallHangup to them so their device stops + * ringing. + * + * Safe to call multiple times for the same peer — any previous timer is + * cancelled first. + */ + private fun schedulePeerTimeout( + peerPubKey: HexKey, + callId: String, + ) { + perPeerTimeoutJobs.remove(peerPubKey)?.cancel() + perPeerTimeoutJobs[peerPubKey] = + scope.launch { + delay(PEER_INVITE_TIMEOUT_MS) + handlePeerTimeout(peerPubKey, callId) + } + } + + /** Cancels the per-peer timer for [peerPubKey], if any. */ + private fun cancelPeerTimeout(peerPubKey: HexKey) { + perPeerTimeoutJobs.remove(peerPubKey)?.cancel() + } + + /** Cancels every per-peer timer. Called on terminal state transitions. */ + private fun cancelAllPeerTimeouts() { + perPeerTimeoutJobs.values.forEach { it.cancel() } + perPeerTimeoutJobs.clear() + } + + /** + * Handles a per-peer timeout firing. Drops the peer from the current + * group call state and publishes a CallHangup to them. + * + * - In [CallState.Offering] the peer is removed from `peerPubKeys`; if no + * peers remain, the whole call ends with [EndReason.TIMEOUT]. + * - In [CallState.Connecting] the peer is removed from `pendingPeerPubKeys`; + * if that leaves nobody connected AND no more pending, the call ends + * with [EndReason.TIMEOUT]. Otherwise the call continues with the + * already-connected peers. + * - In [CallState.Connected] the peer is removed from `pendingPeerPubKeys`; + * at least one other peer is connected by definition, so the call + * always continues. + * + * Fires [onPeerLeft] so the CallController disposes the per-peer + * PeerConnection (and any pending ICE buffers) for the dropped peer. + */ + private suspend fun handlePeerTimeout( + peerPubKey: HexKey, + callId: String, + ) { + var shouldPublishHangup = false + stateMutex.withLock { + perPeerTimeoutJobs.remove(peerPubKey) + when (val current = _state.value) { + is CallState.Offering -> { + if (callId != current.callId) return@withLock + if (peerPubKey !in current.peerPubKeys) return@withLock + Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Offering" } + shouldPublishHangup = true + val remaining = current.peerPubKeys - peerPubKey + if (remaining.isEmpty()) { + transitionToEnded(current.callId, current.peerPubKeys, EndReason.TIMEOUT) + } else { + _state.value = current.copy(peerPubKeys = remaining) + onPeerLeft?.invoke(peerPubKey) + } + } + + is CallState.Connecting -> { + if (callId != current.callId) return@withLock + if (peerPubKey !in current.pendingPeerPubKeys) return@withLock + Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connecting" } + shouldPublishHangup = true + val newPending = current.pendingPeerPubKeys - peerPubKey + if (current.peerPubKeys.isEmpty() && newPending.isEmpty()) { + transitionToEnded( + current.callId, + current.peerPubKeys + current.pendingPeerPubKeys, + EndReason.TIMEOUT, + ) + } else { + _state.value = current.copy(pendingPeerPubKeys = newPending) + onPeerLeft?.invoke(peerPubKey) + } + } + + is CallState.Connected -> { + if (callId != current.callId) return@withLock + if (peerPubKey !in current.pendingPeerPubKeys) return@withLock + Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connected" } + shouldPublishHangup = true + _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - peerPubKey) + onPeerLeft?.invoke(peerPubKey) + } + + else -> { + return@withLock + } + } + } + + if (shouldPublishHangup) { + val result = factory.createHangup(peerPubKey, callId, signer = signer) + publishEvent(result.wrap) + } + } + private fun startTimeout(callId: String) { cancelTimeout() timeoutJob = diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt index ed8c4fb8b..0c487cc0d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -84,6 +85,7 @@ class CallManagerTest { private fun TestScope.createManager( localPubKey: HexKey = bob, followedKeys: Set = setOf(alice, carol), + isCallsEnabled: () -> Boolean = { true }, ): Pair> { val published = mutableListOf() val signer = signers[localPubKey] ?: error("Unknown test identity: $localPubKey") @@ -93,6 +95,7 @@ class CallManagerTest { scope = this, isFollowing = { it in followedKeys }, publishEvent = { published.add(it) }, + isCallsEnabled = isCallsEnabled, ) return manager to published } @@ -1117,6 +1120,359 @@ class CallManagerTest { assertTrue(carol in state.pendingPeerPubKeys, "Invited peer should be in pending set") } + // ======================================================================== + // Mid-Call Invite: existing callees observe the invitee's broadcast answer + // ======================================================================== + + /** + * Bob is already in a Connected group call with Alice. Alice invites Carol. + * Carol's broadcast CallAnswer reaches Bob. Bob's state must expand to + * include Carol in [CallState.Connected.peerPubKeys] and the answer must + * still be forwarded via [CallManager.onAnswerReceived] so the caller-side + * [CallController] can unconditionally initiate a mesh offer to Carol. + */ + @Test + fun midCallInviteAnswerFromUnknownPeerInConnectedExpandsMembership() = + runTest { + val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + + // Bob is in an established 1-1 call with Alice. + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + var forwardedPeer: HexKey? = null + manager.onAnswerReceived = { event -> forwardedPeer = event.pubKey } + + // Alice invited Carol mid-call; Carol broadcasts her acceptance. + // Bob sees a CallAnswer from Carol (unknown peer, same call-id) + // with p-tags covering the whole expanded group {alice, bob, carol}. + val carolAnswer = makeGroupAnswer(from = carol, members = setOf(alice, bob, carol)) + manager.onSignalingEvent(carolAnswer) + + val state = manager.state.value + assertIs(state) + assertTrue(carol in state.peerPubKeys, "Mid-call joiner must be added to peerPubKeys") + assertTrue(alice in state.peerPubKeys, "Existing peer must still be present") + assertEquals(carol, forwardedPeer, "Answer must still be forwarded to CallController") + } + + /** + * Regression: in an initial group call, the callees observing each other's + * answers MUST NOT trip the mid-call expansion branch. The answering peer + * was already part of the group membership set by [acceptCall] (via the + * IncomingCall.groupMembers → Connecting.peerPubKeys transition), so no + * additional insertion should occur. + */ + @Test + fun initialCallAnswerFromKnownPeerDoesNotExpandMembership() = + runTest { + val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + + // Alice calls Bob and Carol as a group. + manager.onSignalingEvent(makeGroupOffer(from = alice, members = setOf(bob, carol))) + assertIs(manager.state.value) + + // Bob accepts. State becomes Connecting(peerPubKeys={alice, carol}). + manager.acceptCall(sdpAnswer) + val connecting = manager.state.value + assertIs(connecting) + assertTrue(alice in connecting.peerPubKeys) + assertTrue(carol in connecting.peerPubKeys) + + val sizeBefore = connecting.peerPubKeys.size + + // Carol — who is already in Bob's tracked membership — answers. + // This is the normal initial-call mesh observation path. + manager.onSignalingEvent(makeGroupAnswer(from = carol, members = setOf(alice, bob, carol))) + + val after = manager.state.value + assertIs(after) + assertEquals(sizeBefore, after.peerPubKeys.size, "Known peer's answer must not grow peerPubKeys") + } + + /** + * Edge case: an existing callee is still in Connecting state (its own ICE + * handshake with the caller hasn't completed yet) when the mid-call + * invitee broadcasts its answer. Membership must still expand so the UI + * shows the new peer. + */ + @Test + fun midCallInviteAnswerFromUnknownPeerInConnectingExpandsMembership() = + runTest { + val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + // Intentionally NOT calling onPeerConnected — we want to stay in + // Connecting for this test. + assertIs(manager.state.value) + + val carolAnswer = makeGroupAnswer(from = carol, members = setOf(alice, bob, carol)) + manager.onSignalingEvent(carolAnswer) + + val state = manager.state.value + assertIs(state) + assertTrue(carol in state.peerPubKeys, "Mid-call joiner must be added while in Connecting") + } + + /** + * Full end-to-end mid-call invite: Alice calls Bob, connects, then invites + * Carol. Verifies the round-trip state on all three CallManagers: + * + * - Alice's pending→connected transition for Carol (caller side) + * - Carol's IncomingCall → Connecting with {alice, bob} as group members + * - Bob's Connected state expanding to include Carol via the broadcast + * answer path + */ + @Test + fun interfaceMidCallInviteFullFlow() = + runTest { + val (aliceManager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + val (bobManager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + val (carolManager, _) = createManager(localPubKey = carol, followedKeys = setOf(alice, bob)) + + // Step 1: Alice calls Bob (1-1). Both reach Connected. + aliceManager.initiateCall(bob, CallType.VIDEO, callId, sdpOffer) + bobManager.onSignalingEvent(makeOffer(from = alice, to = bob, callType = CallType.VIDEO)) + bobManager.acceptCall(sdpAnswer) + aliceManager.onSignalingEvent(makeAnswer(from = bob, to = alice)) + aliceManager.onPeerConnected() + bobManager.onPeerConnected() + assertIs(aliceManager.state.value) + assertIs(bobManager.state.value) + + // Step 2: Alice invites Carol mid-call. + aliceManager.invitePeer(carol, "alice-to-carol-sdp") + val aliceAfterInvite = aliceManager.state.value + assertIs(aliceAfterInvite) + assertTrue(carol in aliceAfterInvite.pendingPeerPubKeys) + + // Step 3: Carol receives the invite offer. Its p-tags cover the + // full expanded group {alice, bob, carol} so Carol sees Bob in her + // group membership from the first event. + val carolOffer = makeGroupOffer(from = alice, members = setOf(alice, bob, carol), callType = CallType.VIDEO) + carolManager.onSignalingEvent(carolOffer) + val carolIncoming = carolManager.state.value + assertIs(carolIncoming) + assertTrue(alice in carolIncoming.groupMembers) + assertTrue(bob in carolIncoming.groupMembers) + + // Step 4: Carol accepts. Her Connecting state must include Bob + // (so later mid-call offers from Bob are handled correctly). + carolManager.acceptCall("carol-answer-sdp") + val carolConnecting = carolManager.state.value + assertIs(carolConnecting) + assertTrue(bob in carolConnecting.peerPubKeys, "Carol's Connecting state must include Bob as a peer") + assertTrue(alice in carolConnecting.peerPubKeys, "Carol's Connecting state must include Alice as a peer") + + // Step 5: Alice receives Carol's answer broadcast. Carol moves + // out of pending into peerPubKeys. + aliceManager.onSignalingEvent( + makeGroupAnswer(from = carol, members = setOf(alice, bob, carol), sdp = "carol-answer-sdp"), + ) + val aliceAfterCarolAnswer = aliceManager.state.value + assertIs(aliceAfterCarolAnswer) + assertTrue(carol in aliceAfterCarolAnswer.peerPubKeys, "Alice should have Carol connected") + assertTrue( + carol !in aliceAfterCarolAnswer.pendingPeerPubKeys, + "Alice should no longer have Carol pending", + ) + + // Step 6: Bob receives Carol's answer broadcast. Bob's state must + // expand to include Carol (mid-call join), and the answer must be + // forwarded so Bob's CallController can initiate a mesh offer. + var bobForwardedAnswer: HexKey? = null + bobManager.onAnswerReceived = { event -> bobForwardedAnswer = event.pubKey } + bobManager.onSignalingEvent( + makeGroupAnswer(from = carol, members = setOf(alice, bob, carol), sdp = "carol-answer-sdp"), + ) + val bobAfterCarolAnswer = bobManager.state.value + assertIs(bobAfterCarolAnswer) + assertTrue(carol in bobAfterCarolAnswer.peerPubKeys, "Bob should add Carol to his membership") + assertEquals(carol, bobForwardedAnswer, "Bob must forward Carol's answer to his CallController") + } + + // ======================================================================== + // Per-peer 30-second invite timeout + // ======================================================================== + + /** + * P2P call: Alice calls Bob, Bob never answers. After 30 s Alice's + * per-peer timer fires and the call ends with [EndReason.TIMEOUT]. The + * timeout hangup is also published so Bob's device stops ringing. + */ + @Test + fun perPeerTimeoutEndsP2PCallWhenBobNeverAnswers() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + published.clear() + + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100) + + val ended = manager.state.value + assertIs(ended) + assertEquals(EndReason.TIMEOUT, ended.reason) + assertEquals( + 1, + published.size, + "Timeout should publish exactly one hangup to the unresponsive peer", + ) + } + + /** + * Group call: Alice offers to Bob + Carol. Bob answers quickly, Carol + * does not. After 30 s Alice's timer for Carol fires and Carol is + * removed from pending. The call continues with Bob. A hangup is + * published to Carol but the Bob leg is untouched. + */ + @Test + fun perPeerTimeoutDropsSlowCalleeFromGroupCall() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + assertIs(manager.state.value) + + // Bob answers quickly (well before the 30 s timer). + manager.onSignalingEvent(makeGroupAnswer(from = bob, members = setOf(alice, bob, carol))) + val afterBob = manager.state.value + assertIs(afterBob) + assertTrue(bob in afterBob.peerPubKeys) + assertTrue(carol in afterBob.pendingPeerPubKeys) + published.clear() + + // Advance past the 30 s per-peer timeout. + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100) + + // Carol dropped; call continues with Bob. + val state = manager.state.value + assertIs(state) + assertTrue(carol !in state.pendingPeerPubKeys, "Carol should be dropped from pending") + assertTrue(bob in state.peerPubKeys, "Bob leg must be untouched") + + assertEquals( + 1, + published.size, + "Timeout must publish exactly one hangup (addressed to Carol)", + ) + } + + /** + * Bob answering inside the 30 s window cancels his per-peer timer. + * Advancing 60 s afterwards must not fire any phantom timeout. + */ + @Test + fun perPeerTimeoutIsCancelledOnAnswer() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + + // Bob answers before the timeout fires. + manager.onSignalingEvent(makeAnswer(from = bob, to = alice)) + assertIs(manager.state.value) + manager.onPeerConnected() + assertIs(manager.state.value) + published.clear() + + // Advance way past the 30 s timeout — no timeout must fire. + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS * 2) + + assertIs(manager.state.value) + assertTrue(published.isEmpty(), "No timeout hangup must be published after a successful answer") + } + + /** + * Mid-call invite: Alice is Connected with Bob, then invites Carol. + * Carol never answers. After 30 s Carol is dropped from + * [CallState.Connected.pendingPeerPubKeys] and the call continues with + * Bob. A hangup is published to Carol. + */ + @Test + fun perPeerTimeoutDropsMidCallInviteeWhenNoAnswer() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + // Alice ↔ Bob Connected. + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + manager.onSignalingEvent(makeAnswer(from = bob, to = alice)) + manager.onPeerConnected() + assertIs(manager.state.value) + + // Alice invites Carol. + manager.invitePeer(carol, "invite-sdp") + val afterInvite = manager.state.value + assertIs(afterInvite) + assertTrue(carol in afterInvite.pendingPeerPubKeys) + published.clear() + + // Carol never answers — advance past the 30 s invite timeout. + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100) + + val state = manager.state.value + assertIs(state) + assertTrue(carol !in state.pendingPeerPubKeys, "Carol should be dropped from pending") + assertTrue(bob in state.peerPubKeys, "Bob leg must be untouched") + + assertEquals( + 1, + published.size, + "Invite timeout must publish exactly one hangup addressed to Carol", + ) + } + + /** + * Bob rejecting a P2P offer cancels his per-peer timer so advancing time + * past 30 s afterwards does not publish a duplicate hangup on top of the + * Ended transition. + */ + @Test + fun perPeerTimeoutIsCancelledOnReject() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + manager.onSignalingEvent(makeReject(from = bob, to = alice)) + val ended = manager.state.value + assertIs(ended) + assertEquals(EndReason.PEER_REJECTED, ended.reason) + + val publishedAfterReject = published.size + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS * 2) + assertEquals( + publishedAfterReject, + published.size, + "No timeout hangup should be published after the peer rejected", + ) + } + + /** + * When a group offer is made, each callee gets its own timer. If NEITHER + * answers, both timers fire in turn and the whole call ends with + * [EndReason.TIMEOUT] (the second timeout has no peers left, so it tips + * the state over the edge). + */ + @Test + fun perPeerTimeoutEndsCallWhenAllCalleesIgnore() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + assertIs(manager.state.value) + + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100) + + val ended = manager.state.value + assertIs(ended) + assertEquals(EndReason.TIMEOUT, ended.reason) + } + @Test fun interfaceFullP2PCallFlowWithRealSigners() = runTest { @@ -1180,4 +1536,71 @@ class CallManagerTest { assertIs(aliceManager.state.value) assertIs(bobManager.state.value) } + + // ======================================================================== + // User has disabled calls in Settings + // ======================================================================== + + /** + * When [CallManager.isCallsEnabled] returns false, an incoming + * [CallOfferEvent] is silently dropped — no state change, no ringing, + * no published reject. + */ + @Test + fun incomingOfferIgnoredWhenCallsDisabledInSettings() = + runTest { + val (manager, published) = createManager(localPubKey = bob, isCallsEnabled = { false }) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + + assertIs(manager.state.value) + assertTrue( + published.isEmpty(), + "Disabled calls must not publish any signaling events in response to an incoming offer", + ) + } + + /** + * Toggling the flag to false after a call is already in progress does + * not affect the in-flight call — CallManager only gates *new* incoming + * offers. Signaling for the active call continues to flow so cleanup + * (hangups, answers, ICE candidates) can complete. + */ + @Test + fun disablingCallsAfterStartDoesNotTearDownInProgressCall() = + runTest { + var enabled = true + val (manager, _) = createManager(localPubKey = bob, isCallsEnabled = { enabled }) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + // User flips the toggle off mid-call. + enabled = false + + // The existing call is unaffected — Bob can still receive + // hangup/answer/ICE traffic for the current call. + assertIs(manager.state.value) + + // But a *new* offer for a different call is silently ignored. + val newCall = makeOffer(from = carol, to = bob, callId = callId2) + manager.onSignalingEvent(newCall) + assertIs(manager.state.value) + } + + /** + * Regression: when calls are enabled (the default) the incoming-offer + * path still works exactly as before. + */ + @Test + fun incomingOfferProcessedWhenCallsEnabled() = + runTest { + val (manager, _) = createManager(localPubKey = bob, isCallsEnabled = { true }) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + + assertIs(manager.state.value) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index 502e86755..4674cf237 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -404,10 +404,53 @@ Callee A (lower pubkey) Callee B (higher pubkey) ICE candidates for callee-to-callee connections follow the same buffering rules as caller-callee connections. +The "discover during ringing" mechanism only works for the **initial** group call, where every callee rings in parallel and observes each other's `CallAnswer` events during their own `IncomingCall` window. For peers that join **after** the initial call is already established (see [Inviting New Peers](#inviting-new-peers)), the symmetric tiebreaker does not apply and a different rule is used. + ### Inviting New Peers To invite a new peer into an active group call, send a Call Offer (kind 25050) with `p` tags listing **all** existing group members plus the new invitee. This allows the invitee to immediately see the full group composition. The SDP in the offer is specific to the new PeerConnection being established, so the wrap is addressed only to the invitee. +#### Mid-Call Mesh Expansion + +When a new peer joins mid-call, the invitee's ringing window happens **after** all existing callees have already answered, so the invitee cannot discover them via the "observe answers during ringing" mechanism. To preserve the full-mesh invariant without introducing new event kinds, clients MUST apply the following asymmetric rule: + +1. **New invitee (passive)**: After accepting a mid-call invite, the invitee sends its `CallAnswer` (kind 25051) gift-wrapped to **every** group member listed in the offer's `p` tags (as with any group acceptance). The invitee MUST NOT initiate any mesh `CallOffer`s after accepting. It only creates additional `PeerConnection`s **reactively**, by handling mid-call `CallOffer`s from existing callees (the same code path that handles any other mid-call offer for the current `call-id`). + +2. **Existing callees (active)**: When a callee in `Connected` state receives a `CallAnswer` for the current `call-id` from a peer that is **not** currently in its tracked group membership, it MUST treat the sender as a mid-call joiner and: + - Add the sender to its group membership. + - **Unconditionally** create a new `PeerConnection`, generate an SDP offer, and send a mesh `CallOffer` (kind 25050) addressed to the sender. The lower-pubkey tiebreaker does NOT apply in this case — only existing Connected callees react to the broadcast answer, so there is exactly one initiator per (existing callee, new invitee) pair and glare is structurally impossible. + +3. **Callees still in `Connecting` state**: If an existing callee is still handshaking with the caller when a mid-call invitee's broadcast answer arrives, it SHOULD also add the sender to its tracked group membership so the UI reflects the expansion, but MAY defer the mesh offer to the new peer until it reaches `Connected`. Implementations that initiate the mesh offer immediately (while still in `Connecting`) are also conformant — the target invitee processes it via the normal mid-call offer handler. + +The asymmetry (invitee passive, existing callees active) is what distinguishes this from the initial-call mesh setup. In the initial call, both sides observe each other's answers during parallel ringing, so the symmetric lower-pubkey tiebreaker is needed to avoid glare. In a mid-call invite, only the existing callees see a new peer appear (via the broadcast answer), so they unilaterally become the initiators. + +``` +Caller A Existing callee B Existing callee C New invitee D + | | | | + |-- CallOffer (invite) ------------------------------------------------------>| + | (p-tags: A, B, C, D; wrapped only to D) | + | | | | + | | | [D rings → accepts] + |<-- CallAnswer ------------|-----------------------|-- (broadcast) ----------| + | |<----------------------|-- (broadcast) ----------| + | | |<- (broadcast) ----------| + | | | | + | [A: D was pending, | [B: D is new → | [C: D is new → | + | move to peerPubKeys] | add to group, | add to group, | + | | initiate mesh] | initiate mesh] | + | | | | + | |-- mesh CallOffer ------------------------------>| + | | |-- mesh CallOffer ----->| + | | | | + | | | [D: mid-call offer + | | | handler creates + | | | PCs for B and C] + | |<-- mesh CallAnswer --------------------------- | + | | |<-- mesh CallAnswer ----| + | | | | + |====== Full mesh: A↔B, A↔C, A↔D, B↔C, B↔D, C↔D =========================== | +``` + ### Partial Disconnects When a peer's ICE connection fails or they send a hangup in a group call, clients MUST close only that peer's `PeerConnection` and continue the call with remaining peers. The call ends only when all peers have disconnected. @@ -437,7 +480,8 @@ This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: - The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. - Signaling data is ephemeral and has no long-term value. Using kind `21059` (ephemeral gift wrap) signals to relays that these events are transient and SHOULD NOT be persisted. -- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. +- **Callee ringing timeout**: Clients SHOULD implement a ringing timeout (e.g., 60 seconds) on the callee side. If the user does not accept within the window, the call transitions to a "timed out" state. +- **Caller per-peer invite timeout**: On the caller side in a group call, clients SHOULD track an independent per-peer invite timer (e.g., 30 seconds) for every peer in `pendingPeerPubKeys` (or for every peer in `Offering.peerPubKeys`, which is implicitly pending). When a peer's timer fires without an answer, the caller drops that peer from the current group call, publishes a `CallHangup` (kind 25053) addressed to them (so their device stops ringing), and continues the call with the remaining peers. If the resulting state has zero connected peers and zero pending peers, the call ends with `TIMEOUT`. The same timer is started when inviting a new peer mid-call. This ensures slow or unavailable peers do not stall the rest of the mesh. - After a call ends, the call state SHOULD auto-reset to idle after a brief display period (e.g., 2 seconds) to ensure the client is ready for subsequent calls. ### WebRTC Configuration