From e072b9ac99067c2a57a22674902842d39cbee9ca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:22:41 +0000 Subject: [PATCH] fix: buffer ICE candidates and discard stale signaling events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for call connectivity: 1. ICE candidate buffering: Candidates arriving before the WebRTC session exists (callee ringing) or before remote description is set (caller waiting for answer) are now queued in pendingIceCandidates and flushed once setRemoteDescription is called. This was the root cause of calls getting stuck at "Connecting" — ICE candidates were silently dropped. 2. Stale event filter: All signaling events older than 30 seconds are discarded in CallManager.onSignalingEvent() to prevent old cached events from triggering phantom calls. Also: removed cleanup() from WebRTC onDisconnected callback to avoid double-cleanup race with the CallManager state observer. https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- .../amethyst/service/call/CallController.kt | 26 +++++++++++++++++-- .../amethyst/commons/call/CallManager.kt | 5 ++++ 2 files changed, 29 insertions(+), 2 deletions(-) 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 a91ff58aa..9280010ef 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 @@ -35,6 +35,7 @@ import org.webrtc.IceCandidate import org.webrtc.MediaStream import org.webrtc.SessionDescription import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList class CallController( private val context: Context, @@ -47,6 +48,8 @@ class CallController( private val callFactory = WebRtcCallFactory() private var currentCallId: String? = null private var currentPeerPubKey: HexKey? = null + private var remoteDescriptionSet = false + private val pendingIceCandidates = CopyOnWriteArrayList() init { scope.launch { @@ -65,6 +68,8 @@ class CallController( val callId = UUID.randomUUID().toString() currentCallId = callId currentPeerPubKey = peerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() createWebRtcSession() webRtcSession?.addAudioTrack() @@ -85,6 +90,8 @@ class CallController( currentCallId = state.callId currentPeerPubKey = state.callerPubKey + remoteDescriptionSet = false + pendingIceCandidates.clear() createWebRtcSession() webRtcSession?.addAudioTrack() @@ -95,6 +102,7 @@ class CallController( webRtcSession?.setRemoteDescription( SessionDescription(SessionDescription.Type.OFFER, sdpOffer), ) + flushPendingIceCandidates() webRtcSession?.createAnswer { sdp -> scope.launch { @@ -107,18 +115,31 @@ class CallController( webRtcSession?.setRemoteDescription( SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), ) + flushPendingIceCandidates() } fun onIceCandidateReceived(event: CallIceCandidateEvent) { val json = event.candidateJson() try { val candidate = parseIceCandidate(json) - webRtcSession?.addIceCandidate(candidate) + if (webRtcSession != null && remoteDescriptionSet) { + webRtcSession?.addIceCandidate(candidate) + } else { + pendingIceCandidates.add(candidate) + } } catch (_: Exception) { // Ignore malformed ICE candidates } } + private fun flushPendingIceCandidates() { + remoteDescriptionSet = true + val session = webRtcSession ?: return + val candidates = pendingIceCandidates.toList() + pendingIceCandidates.clear() + candidates.forEach { session.addIceCandidate(it) } + } + fun hangup() { scope.launch { callManager.hangup() } cleanup() @@ -130,6 +151,8 @@ class CallController( webRtcSession = null currentCallId = null currentPeerPubKey = null + remoteDescriptionSet = false + pendingIceCandidates.clear() } private fun createWebRtcSession() { @@ -147,7 +170,6 @@ class CallController( onRemoteStream = { _: MediaStream -> }, onDisconnected = { scope.launch { callManager.hangup() } - cleanup() }, ) webRtcSession?.initialize() 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 6527c80d9..0ad79e1f3 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 @@ -60,8 +60,11 @@ class CallManager( companion object { const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting + const val MAX_EVENT_AGE_SECONDS = 30L // discard signaling events older than this } + private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS + suspend fun initiateCall( calleePubKey: HexKey, callType: CallType, @@ -194,6 +197,8 @@ class CallManager( } fun onSignalingEvent(event: Event) { + if (isEventTooOld(event)) return + when (event) { is CallOfferEvent -> onIncomingCallEvent(event) is CallAnswerEvent -> onCallAnswered(event)