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.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<IceCandidate>()
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()
@@ -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)