fix: buffer ICE candidates and discard stale signaling events

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
This commit is contained in:
Claude
2026-04-02 00:22:41 +00:00
parent 8ddec3a0d7
commit 0688a46604
2 changed files with 29 additions and 2 deletions
@@ -35,6 +35,7 @@ import org.webrtc.IceCandidate
import org.webrtc.MediaStream import org.webrtc.MediaStream
import org.webrtc.SessionDescription import org.webrtc.SessionDescription
import java.util.UUID import java.util.UUID
import java.util.concurrent.CopyOnWriteArrayList
class CallController( class CallController(
private val context: Context, private val context: Context,
@@ -47,6 +48,8 @@ class CallController(
private val callFactory = WebRtcCallFactory() private val callFactory = WebRtcCallFactory()
private var currentCallId: String? = null private var currentCallId: String? = null
private var currentPeerPubKey: HexKey? = null private var currentPeerPubKey: HexKey? = null
private var remoteDescriptionSet = false
private val pendingIceCandidates = CopyOnWriteArrayList<IceCandidate>()
init { init {
scope.launch { scope.launch {
@@ -65,6 +68,8 @@ class CallController(
val callId = UUID.randomUUID().toString() val callId = UUID.randomUUID().toString()
currentCallId = callId currentCallId = callId
currentPeerPubKey = peerPubKey currentPeerPubKey = peerPubKey
remoteDescriptionSet = false
pendingIceCandidates.clear()
createWebRtcSession() createWebRtcSession()
webRtcSession?.addAudioTrack() webRtcSession?.addAudioTrack()
@@ -85,6 +90,8 @@ class CallController(
currentCallId = state.callId currentCallId = state.callId
currentPeerPubKey = state.callerPubKey currentPeerPubKey = state.callerPubKey
remoteDescriptionSet = false
pendingIceCandidates.clear()
createWebRtcSession() createWebRtcSession()
webRtcSession?.addAudioTrack() webRtcSession?.addAudioTrack()
@@ -95,6 +102,7 @@ class CallController(
webRtcSession?.setRemoteDescription( webRtcSession?.setRemoteDescription(
SessionDescription(SessionDescription.Type.OFFER, sdpOffer), SessionDescription(SessionDescription.Type.OFFER, sdpOffer),
) )
flushPendingIceCandidates()
webRtcSession?.createAnswer { sdp -> webRtcSession?.createAnswer { sdp ->
scope.launch { scope.launch {
@@ -107,18 +115,31 @@ class CallController(
webRtcSession?.setRemoteDescription( webRtcSession?.setRemoteDescription(
SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer), SessionDescription(SessionDescription.Type.ANSWER, sdpAnswer),
) )
flushPendingIceCandidates()
} }
fun onIceCandidateReceived(event: CallIceCandidateEvent) { fun onIceCandidateReceived(event: CallIceCandidateEvent) {
val json = event.candidateJson() val json = event.candidateJson()
try { try {
val candidate = parseIceCandidate(json) val candidate = parseIceCandidate(json)
webRtcSession?.addIceCandidate(candidate) if (webRtcSession != null && remoteDescriptionSet) {
webRtcSession?.addIceCandidate(candidate)
} else {
pendingIceCandidates.add(candidate)
}
} catch (_: Exception) { } catch (_: Exception) {
// Ignore malformed ICE candidates // 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() { fun hangup() {
scope.launch { callManager.hangup() } scope.launch { callManager.hangup() }
cleanup() cleanup()
@@ -130,6 +151,8 @@ class CallController(
webRtcSession = null webRtcSession = null
currentCallId = null currentCallId = null
currentPeerPubKey = null currentPeerPubKey = null
remoteDescriptionSet = false
pendingIceCandidates.clear()
} }
private fun createWebRtcSession() { private fun createWebRtcSession() {
@@ -147,7 +170,6 @@ class CallController(
onRemoteStream = { _: MediaStream -> }, onRemoteStream = { _: MediaStream -> },
onDisconnected = { onDisconnected = {
scope.launch { callManager.hangup() } scope.launch { callManager.hangup() }
cleanup()
}, },
) )
webRtcSession?.initialize() webRtcSession?.initialize()
@@ -60,8 +60,11 @@ class CallManager(
companion object { companion object {
const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout 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 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( suspend fun initiateCall(
calleePubKey: HexKey, calleePubKey: HexKey,
callType: CallType, callType: CallType,
@@ -194,6 +197,8 @@ class CallManager(
} }
fun onSignalingEvent(event: Event) { fun onSignalingEvent(event: Event) {
if (isEventTooOld(event)) return
when (event) { when (event) {
is CallOfferEvent -> onIncomingCallEvent(event) is CallOfferEvent -> onIncomingCallEvent(event)
is CallAnswerEvent -> onCallAnswered(event) is CallAnswerEvent -> onCallAnswered(event)