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:
@@ -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)
|
||||||
|
if (webRtcSession != null && remoteDescriptionSet) {
|
||||||
webRtcSession?.addIceCandidate(candidate)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user