Merge pull request #2110 from vitorpamplona/claude/debug-webrtc-calls-KqELh

Improve group call peer management and signaling robustness
This commit is contained in:
Vitor Pamplona
2026-04-03 15:01:22 -04:00
committed by GitHub
3 changed files with 111 additions and 17 deletions
@@ -64,10 +64,17 @@ class CallManager(
/** Called when a mid-call offer is received from another callee in a group call. */
var onMidCallOfferReceived: ((peerPubKey: HexKey, sdpOffer: String) -> Unit)? = null
/** Called when a peer leaves the call (hangup) but the call continues with remaining peers. */
var onPeerLeft: ((peerPubKey: HexKey) -> Unit)? = null
private var timeoutJob: Job? = null
private var resetJob: Job? = null
private val processedEventIds = mutableSetOf<String>()
/** Peers whose answers we saw while still ringing (IncomingCall).
* After we accept, we trigger callee-to-callee mesh setup with them. */
private val discoveredCalleePeers = mutableSetOf<HexKey>()
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
@@ -220,6 +227,17 @@ class CallManager(
val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer)
result.wraps.forEach { publishEvent(it) }
Log.d("CallManager") { "acceptCall: answer published, now in Connecting state" }
// Trigger callee-to-callee mesh connections with peers we discovered
// while still ringing (their answers arrived before we accepted).
val discovered = discoveredCalleePeers.toSet()
discoveredCalleePeers.clear()
if (discovered.isNotEmpty()) {
Log.d("CallManager") { "acceptCall: triggering mesh setup with ${discovered.size} discovered peers: ${discovered.map { it.take(8) }}" }
for (peer in discovered) {
onNewPeerInGroupCall?.invoke(peer)
}
}
}
suspend fun rejectCall() {
@@ -243,6 +261,18 @@ class CallManager(
"currentState=${current::class.simpleName}, sdpAnswerLength=${event.sdpAnswer().length}"
}
// Self-answer: only meaningful as "answered elsewhere" in IncomingCall.
// In all other states it's our own echo from the relay — ignore it.
if (answeringPeer == signer.pubKey) {
if (current is CallState.IncomingCall && callId == current.callId) {
Log.d("CallManager") { "onCallAnswered: self-answer detected in IncomingCall — answered elsewhere" }
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.ANSWERED_ELSEWHERE)
} else {
Log.d("CallManager") { "onCallAnswered: ignoring self-answer echo in ${current::class.simpleName}" }
}
return
}
when (current) {
is CallState.Offering -> {
if (callId != current.callId) {
@@ -272,20 +302,17 @@ class CallManager(
)
}
// Forward to CallController — it routes to the correct PeerSession
Log.d("CallManager") { "onCallAnswered: additional peer ${answeringPeer.take(8)} in Connecting, forwarding" }
// and internally triggers callee-to-callee mesh setup if needed.
Log.d("CallManager") { "onCallAnswered: additional peer ${answeringPeer.take(8)} in Connecting, forwarding to CallController" }
onAnswerReceived?.invoke(event)
// Notify callees about new peer for mesh setup
onNewPeerInGroupCall?.invoke(answeringPeer)
}
is CallState.IncomingCall -> {
if (callId != current.callId) return
if (answeringPeer == signer.pubKey) {
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.ANSWERED_ELSEWHERE)
}
// Another group member answered — notify for future mesh connections
// (we'll connect to them after we accept the call ourselves)
// Another group member answered — remember them for mesh setup
// after we accept the call ourselves.
Log.d("CallManager") { "onCallAnswered: peer ${answeringPeer.take(8)} answered while we're still ringing, storing for later mesh" }
discoveredCalleePeers.add(answeringPeer)
}
is CallState.Connected -> {
@@ -296,13 +323,11 @@ class CallManager(
peerPubKeys = current.peerPubKeys + answeringPeer,
pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer,
)
// Forward to CallController for routing
onAnswerReceived?.invoke(event)
onNewPeerInGroupCall?.invoke(answeringPeer)
} else {
// Renegotiation answer
onAnswerReceived?.invoke(event)
}
// Forward to CallController — it routes to the correct PeerSession
// and internally triggers callee-to-callee mesh setup if needed.
Log.d("CallManager") { "onCallAnswered: peer ${answeringPeer.take(8)} answer in Connected, forwarding to CallController" }
onAnswerReceived?.invoke(event)
}
else -> {
@@ -487,19 +512,24 @@ class CallManager(
val callId = event.callId() ?: return
val leavingPeer = event.pubKey
Log.d("CallManager") { "onPeerHangup: from=${leavingPeer.take(8)}, callId=$callId, state=${current::class.simpleName}" }
when (current) {
is CallState.Connected -> {
if (callId != current.callId) return
val connectedRemaining = current.peerPubKeys - leavingPeer
val pendingRemaining = current.pendingPeerPubKeys - leavingPeer
if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) {
Log.d("CallManager") { "onPeerHangup: last peer left, ending call" }
transitionToEnded(callId, current.allPeerPubKeys, EndReason.PEER_HANGUP)
} else {
Log.d("CallManager") { "onPeerHangup: ${leavingPeer.take(8)} left, remaining=${connectedRemaining.map { it.take(8) }}, pending=${pendingRemaining.map { it.take(8) }}" }
_state.value =
current.copy(
peerPubKeys = connectedRemaining,
pendingPeerPubKeys = pendingRemaining,
)
onPeerLeft?.invoke(leavingPeer)
}
}
@@ -508,13 +538,16 @@ class CallManager(
val connectedRemaining = current.peerPubKeys - leavingPeer
val pendingRemaining = current.pendingPeerPubKeys - leavingPeer
if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) {
Log.d("CallManager") { "onPeerHangup: last peer left during connecting, ending call" }
transitionToEnded(callId, current.peerPubKeys + current.pendingPeerPubKeys, EndReason.PEER_HANGUP)
} else {
Log.d("CallManager") { "onPeerHangup: ${leavingPeer.take(8)} left during connecting, remaining=${connectedRemaining.map { it.take(8) }}" }
_state.value =
current.copy(
peerPubKeys = connectedRemaining,
pendingPeerPubKeys = pendingRemaining,
)
onPeerLeft?.invoke(leavingPeer)
}
}
@@ -525,6 +558,7 @@ class CallManager(
transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP)
} else {
_state.value = current.copy(peerPubKeys = remaining)
onPeerLeft?.invoke(leavingPeer)
}
}
@@ -555,6 +589,16 @@ class CallManager(
}
if (!processedEventIds.add(event.id)) return
// Filter out our own ICE candidates and hangups echoed back from relays.
// These are never useful: ICE candidates are for the remote peer, and
// hangups are already handled locally by hangup() → transitionToEnded.
// Self-answers and self-rejects are NOT filtered here because they serve
// as "answered/rejected elsewhere" signals when in IncomingCall state.
if (event.pubKey == signer.pubKey && (event is CallIceCandidateEvent || event is CallHangupEvent)) {
Log.d("CallManager") { "Ignoring self-event kind=${event.kind} id=${event.id.take(8)}" }
return
}
Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" }
when (event) {
@@ -604,6 +648,7 @@ class CallManager(
resetJob?.cancel()
resetJob = null
processedEventIds.clear()
discoveredCalleePeers.clear()
}
private fun transitionToEnded(