diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt index 62d7829b1..e2a0546bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt @@ -21,7 +21,12 @@ package com.vitorpamplona.amethyst.service.call import com.vitorpamplona.amethyst.commons.call.CallManager +import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch /** * Process-level singleton that bridges the active [CallManager] and @@ -47,7 +52,26 @@ object CallSessionBridge { this.accountViewModel = accountViewModel } + /** + * Terminates any active call and clears all references. Called from + * [AccountViewModel.onCleared] during logout or account switch so + * that a stale CallSession cannot invoke signing/publishing lambdas + * on a disposed Account. + */ fun clear() { + val mgr = callManager + if (mgr != null) { + val state = mgr.state.value + if (state is CallState.IncomingCall || + state is CallState.Offering || + state is CallState.Connecting || + state is CallState.Connected + ) { + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { + mgr.hangup() + } + } + } callManager = null accountViewModel = null } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt index d80710bb5..bdd3f81df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt @@ -589,22 +589,36 @@ class CallSession( WebRtcCallSession( peerConnectionFactory = factory, iceServers = IceServerConfig.buildIceServers(settingsProvider().callTurnServers), - onIceCandidate = { candidate -> onLocalIceCandidate(peerPubKey, candidate) }, + onIceCandidate = { candidate -> + if (!closed) onLocalIceCandidate(peerPubKey, candidate) + }, onPeerConnected = { + if (closed) return@WebRtcCallSession Log.d(TAG) { "Peer ${peerPubKey.take(8)} connected!" } scope.launch { + if (closed) return@launch callManager.onPeerConnected() if (callManager.state.value is CallState.Connected) { ensureForegroundService() } } }, - onRemoteVideoTrack = { track -> videoMonitor.onRemoteVideoTrack(peerPubKey, track) }, - onDisconnected = { scope.launch { onPeerDisconnected(peerPubKey) } }, - onError = { error -> _errorMessage.value = error }, - onRenegotiationNeeded = { performRenegotiation(peerPubKey) }, + onRemoteVideoTrack = { track -> + if (!closed) videoMonitor.onRemoteVideoTrack(peerPubKey, track) + }, + onDisconnected = { + if (!closed) scope.launch { onPeerDisconnected(peerPubKey) } + }, + onError = { error -> + if (!closed) _errorMessage.value = error + }, + onRenegotiationNeeded = { + if (!closed) performRenegotiation(peerPubKey) + }, onIceRestartOffer = { sdp -> - scope.launch { callManager.sendRenegotiation(sdp.description, peerPubKey) } + if (!closed) { + scope.launch { callManager.sendRenegotiation(sdp.description, peerPubKey) } + } }, ) try { 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 e666ef84e..99a16b5f3 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 @@ -113,6 +113,7 @@ class CallManager( */ private val watchdogScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var ringingWatchdogJob: Job? = null + private var connectingWatchdogJob: Job? = null /** Per-peer invite timeout jobs. A separate 30-second timer is scheduled * for each peer we are waiting on (initial group-call offerees and @@ -152,6 +153,7 @@ class CallManager( companion object { 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 CONNECTING_TIMEOUT_MS = 30_000L // 30 seconds to establish ICE connection 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 @@ -415,6 +417,7 @@ class CallManager( ) cancelTimeout() disarmRingingWatchdog() + armConnectingWatchdog(current.callId) // Local watchdog timers only — we are not the inviter for these // peers, so [handlePeerTimeout] will silently drop them without // publishing any hangup (see `peersInvitedByUs`). @@ -512,7 +515,8 @@ class CallManager( // still in `pending` keep theirs (scheduled in beginOffering). cancelPeerTimeout(answeringPeer) disarmRingingWatchdog() - Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" } + armConnectingWatchdog(current.callId) + Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallSession" } onAnswerReceived?.invoke(event) } @@ -710,6 +714,7 @@ class CallManager( } Log.d("CallManager") { "onPeerConnected: Connecting -> Connected! callId=${current.callId}" } + disarmConnectingWatchdog() _state.value = CallState.Connected( callId = current.callId, @@ -998,6 +1003,7 @@ class CallManager( cancelTimeout() cancelAllPeerTimeouts() disarmRingingWatchdog() + disarmConnectingWatchdog() resetJob?.cancel() resetJob = null processedEventIds.clear() @@ -1017,6 +1023,7 @@ class CallManager( cancelTimeout() cancelAllPeerTimeouts() disarmRingingWatchdog() + disarmConnectingWatchdog() resetJob?.cancel() resetJob = scope.launch { @@ -1065,12 +1072,41 @@ class CallManager( ringingWatchdogJob = null } + // ---- Connecting watchdog ---- + + /** + * Arms a timer for [callId] in `Connecting` state. If ICE negotiation + * does not promote the state to `Connected` within + * [CONNECTING_TIMEOUT_MS], the call ends with [EndReason.TIMEOUT]. + * Disarmed when entering `Connected`, `Ended`, or `Idle`. + */ + private fun armConnectingWatchdog(callId: String) { + connectingWatchdogJob?.cancel() + connectingWatchdogJob = + watchdogScope.launch { + delay(CONNECTING_TIMEOUT_MS) + stateMutex.withLock { + val cur = _state.value + if (cur is CallState.Connecting && cur.callId == callId) { + Log.d("CallManager") { "Connecting watchdog fired for $callId — forcing Ended(TIMEOUT)" } + transitionToEnded(callId, cur.peerPubKeys + cur.pendingPeerPubKeys, EndReason.TIMEOUT) + } + } + } + } + + private fun disarmConnectingWatchdog() { + connectingWatchdogJob?.cancel() + connectingWatchdogJob = null + } + /** * Cancels all long-lived coroutines owned by this manager. Called when * the owning account scope is torn down (logout / process shutdown). */ fun dispose() { disarmRingingWatchdog() + disarmConnectingWatchdog() watchdogScope.cancel() }