Merge pull request #2110 from vitorpamplona/claude/debug-webrtc-calls-KqELh
Improve group call peer management and signaling robustness
This commit is contained in:
@@ -349,6 +349,7 @@ class CallController(
|
|||||||
peerPubKey: HexKey,
|
peerPubKey: HexKey,
|
||||||
sdpAnswer: String,
|
sdpAnswer: String,
|
||||||
) {
|
) {
|
||||||
|
Log.d(TAG) { "onCallAnswerReceived: from=${peerPubKey.take(8)}, knownSessions=${peerSessions.keys.map { it.take(8) }}" }
|
||||||
val ps = peerSessions[peerPubKey]
|
val ps = peerSessions[peerPubKey]
|
||||||
if (ps != null) {
|
if (ps != null) {
|
||||||
// We have a PeerConnection for this peer — set remote description
|
// We have a PeerConnection for this peer — set remote description
|
||||||
@@ -437,10 +438,14 @@ class CallController(
|
|||||||
* peer with the lexicographically lower pubkey initiates.
|
* peer with the lexicographically lower pubkey initiates.
|
||||||
*/
|
*/
|
||||||
fun onNewPeerInGroupCall(peerPubKey: HexKey) {
|
fun onNewPeerInGroupCall(peerPubKey: HexKey) {
|
||||||
if (peerSessions.containsKey(peerPubKey)) return // already connected/connecting
|
if (peerSessions.containsKey(peerPubKey)) {
|
||||||
|
Log.d(TAG) { "onNewPeerInGroupCall: session already exists for ${peerPubKey.take(8)} — skipping" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val myPubKey = signerProvider().pubKey
|
val myPubKey = signerProvider().pubKey
|
||||||
|
Log.d(TAG) { "onNewPeerInGroupCall: peer=${peerPubKey.take(8)}, myPubKey=${myPubKey.take(8)}, iAmLower=${myPubKey < peerPubKey}" }
|
||||||
if (myPubKey < peerPubKey) {
|
if (myPubKey < peerPubKey) {
|
||||||
Log.d(TAG) { "Initiating callee-to-callee connection to ${peerPubKey.take(8)} (I have lower pubkey)" }
|
Log.d(TAG) { "Initiating callee-to-callee connection to ${peerPubKey.take(8)} (I have lower pubkey)" }
|
||||||
createAndOfferToPeer(peerPubKey)
|
createAndOfferToPeer(peerPubKey)
|
||||||
@@ -671,6 +676,7 @@ class CallController(
|
|||||||
// ---- Per-peer PeerConnection creation ----
|
// ---- Per-peer PeerConnection creation ----
|
||||||
|
|
||||||
private fun createPeerSession(peerPubKey: HexKey): PeerSessionState {
|
private fun createPeerSession(peerPubKey: HexKey): PeerSessionState {
|
||||||
|
Log.d(TAG) { "createPeerSession: ${peerPubKey.take(8)}, existing sessions=${peerSessions.keys.map { it.take(8) }}" }
|
||||||
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
|
val factory = peerConnectionFactory ?: throw IllegalStateException("PeerConnectionFactory not initialized")
|
||||||
|
|
||||||
val session =
|
val session =
|
||||||
@@ -722,20 +728,62 @@ class CallController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun onPeerDisconnected(peerPubKey: HexKey) {
|
private fun onPeerDisconnected(peerPubKey: HexKey) {
|
||||||
Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected" }
|
Log.d(TAG) { "Peer ${peerPubKey.take(8)} disconnected (ICE FAILED)" }
|
||||||
// If all peers disconnected, hang up
|
// If all peers disconnected, hang up
|
||||||
|
val sessionStates = peerSessions.map { (key, ps) -> "${key.take(8)}=${ps.session.getSignalingState()}" }
|
||||||
|
Log.d(TAG) { "onPeerDisconnected: checking remaining sessions: $sessionStates" }
|
||||||
val allDisconnected =
|
val allDisconnected =
|
||||||
peerSessions.keys.all { key ->
|
peerSessions.keys.all { key ->
|
||||||
key == peerPubKey || peerSessions[key]?.session?.getSignalingState() == PeerConnection.SignalingState.CLOSED
|
key == peerPubKey || peerSessions[key]?.session?.getSignalingState() == PeerConnection.SignalingState.CLOSED
|
||||||
}
|
}
|
||||||
if (allDisconnected) {
|
if (allDisconnected) {
|
||||||
|
Log.d(TAG) { "onPeerDisconnected: all peers disconnected, hanging up" }
|
||||||
scope.launch { callManager.hangup() }
|
scope.launch { callManager.hangup() }
|
||||||
|
} else {
|
||||||
|
Log.d(TAG) { "onPeerDisconnected: other peers still active, continuing call" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Per-peer cleanup ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes a single peer's WebRTC session when they leave the call
|
||||||
|
* but the call continues with remaining peers.
|
||||||
|
*/
|
||||||
|
fun disposePeerSession(peerPubKey: HexKey) {
|
||||||
|
val ps = peerSessions.remove(peerPubKey)
|
||||||
|
if (ps != null) {
|
||||||
|
Log.d(TAG) { "disposePeerSession: closing session for ${peerPubKey.take(8)}, remaining sessions=${peerSessions.keys.map { it.take(8) }}" }
|
||||||
|
try {
|
||||||
|
ps.session.dispose()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "disposePeerSession: dispose() failed for ${peerPubKey.take(8)}", e)
|
||||||
|
}
|
||||||
|
// Update remote video tracks
|
||||||
|
val currentTracks = _remoteVideoTracks.value
|
||||||
|
if (peerPubKey in currentTracks) {
|
||||||
|
_remoteVideoTracks.value = currentTracks - peerPubKey
|
||||||
|
// If the disposed peer was the primary remote track, pick a new one
|
||||||
|
if (_remoteVideoTrack.value == currentTracks[peerPubKey]) {
|
||||||
|
stopRemoteVideoMonitor()
|
||||||
|
val nextTrack = _remoteVideoTracks.value.values.firstOrNull()
|
||||||
|
_remoteVideoTrack.value = nextTrack
|
||||||
|
if (nextTrack != null) {
|
||||||
|
startRemoteVideoMonitor(nextTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG) { "disposePeerSession: no session for ${peerPubKey.take(8)} (already disposed or never created)" }
|
||||||
|
}
|
||||||
|
// Clean up any buffered ICE candidates for this peer
|
||||||
|
globalPendingIce.remove(peerPubKey)
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Cleanup ----
|
// ---- Cleanup ----
|
||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
|
Log.d(TAG) { "cleanup: disposing ${peerSessions.size} peer sessions, state=${callManager.state.value::class.simpleName}" }
|
||||||
// Each block is wrapped individually so that a failure in one
|
// Each block is wrapped individually so that a failure in one
|
||||||
// (e.g. a WebRTC native crash) does not prevent the rest from
|
// (e.g. a WebRTC native crash) does not prevent the rest from
|
||||||
// running. Without this, a single exception could leave the
|
// running. Without this, a single exception could leave the
|
||||||
|
|||||||
+1
@@ -225,6 +225,7 @@ class AccountViewModel(
|
|||||||
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
|
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
|
||||||
callManager.onNewPeerInGroupCall = { peerPubKey -> controller.onNewPeerInGroupCall(peerPubKey) }
|
callManager.onNewPeerInGroupCall = { peerPubKey -> controller.onNewPeerInGroupCall(peerPubKey) }
|
||||||
callManager.onMidCallOfferReceived = { peerPubKey, sdpOffer -> controller.onMidCallOfferReceived(peerPubKey, sdpOffer) }
|
callManager.onMidCallOfferReceived = { peerPubKey, sdpOffer -> controller.onMidCallOfferReceived(peerPubKey, sdpOffer) }
|
||||||
|
callManager.onPeerLeft = { peerPubKey -> controller.disposePeerSession(peerPubKey) }
|
||||||
callController = controller
|
callController = controller
|
||||||
|
|
||||||
// Populate ActiveCallHolder so CallActivity can launch even when the app
|
// Populate ActiveCallHolder so CallActivity can launch even when the app
|
||||||
|
|||||||
+60
-15
@@ -64,10 +64,17 @@ class CallManager(
|
|||||||
/** Called when a mid-call offer is received from another callee in a group call. */
|
/** Called when a mid-call offer is received from another callee in a group call. */
|
||||||
var onMidCallOfferReceived: ((peerPubKey: HexKey, sdpOffer: String) -> Unit)? = null
|
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 timeoutJob: Job? = null
|
||||||
private var resetJob: Job? = null
|
private var resetJob: Job? = null
|
||||||
private val processedEventIds = mutableSetOf<String>()
|
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 {
|
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
|
||||||
@@ -220,6 +227,17 @@ class CallManager(
|
|||||||
val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer)
|
val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer)
|
||||||
result.wraps.forEach { publishEvent(it) }
|
result.wraps.forEach { publishEvent(it) }
|
||||||
Log.d("CallManager") { "acceptCall: answer published, now in Connecting state" }
|
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() {
|
suspend fun rejectCall() {
|
||||||
@@ -243,6 +261,18 @@ class CallManager(
|
|||||||
"currentState=${current::class.simpleName}, sdpAnswerLength=${event.sdpAnswer().length}"
|
"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) {
|
when (current) {
|
||||||
is CallState.Offering -> {
|
is CallState.Offering -> {
|
||||||
if (callId != current.callId) {
|
if (callId != current.callId) {
|
||||||
@@ -272,20 +302,17 @@ class CallManager(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Forward to CallController — it routes to the correct PeerSession
|
// 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)
|
onAnswerReceived?.invoke(event)
|
||||||
|
|
||||||
// Notify callees about new peer for mesh setup
|
|
||||||
onNewPeerInGroupCall?.invoke(answeringPeer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is CallState.IncomingCall -> {
|
is CallState.IncomingCall -> {
|
||||||
if (callId != current.callId) return
|
if (callId != current.callId) return
|
||||||
if (answeringPeer == signer.pubKey) {
|
// Another group member answered — remember them for mesh setup
|
||||||
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.ANSWERED_ELSEWHERE)
|
// after we accept the call ourselves.
|
||||||
}
|
Log.d("CallManager") { "onCallAnswered: peer ${answeringPeer.take(8)} answered while we're still ringing, storing for later mesh" }
|
||||||
// Another group member answered — notify for future mesh connections
|
discoveredCalleePeers.add(answeringPeer)
|
||||||
// (we'll connect to them after we accept the call ourselves)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is CallState.Connected -> {
|
is CallState.Connected -> {
|
||||||
@@ -296,13 +323,11 @@ class CallManager(
|
|||||||
peerPubKeys = current.peerPubKeys + answeringPeer,
|
peerPubKeys = current.peerPubKeys + answeringPeer,
|
||||||
pendingPeerPubKeys = current.pendingPeerPubKeys - 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 -> {
|
else -> {
|
||||||
@@ -487,19 +512,24 @@ class CallManager(
|
|||||||
val callId = event.callId() ?: return
|
val callId = event.callId() ?: return
|
||||||
val leavingPeer = event.pubKey
|
val leavingPeer = event.pubKey
|
||||||
|
|
||||||
|
Log.d("CallManager") { "onPeerHangup: from=${leavingPeer.take(8)}, callId=$callId, state=${current::class.simpleName}" }
|
||||||
|
|
||||||
when (current) {
|
when (current) {
|
||||||
is CallState.Connected -> {
|
is CallState.Connected -> {
|
||||||
if (callId != current.callId) return
|
if (callId != current.callId) return
|
||||||
val connectedRemaining = current.peerPubKeys - leavingPeer
|
val connectedRemaining = current.peerPubKeys - leavingPeer
|
||||||
val pendingRemaining = current.pendingPeerPubKeys - leavingPeer
|
val pendingRemaining = current.pendingPeerPubKeys - leavingPeer
|
||||||
if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) {
|
if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) {
|
||||||
|
Log.d("CallManager") { "onPeerHangup: last peer left, ending call" }
|
||||||
transitionToEnded(callId, current.allPeerPubKeys, EndReason.PEER_HANGUP)
|
transitionToEnded(callId, current.allPeerPubKeys, EndReason.PEER_HANGUP)
|
||||||
} else {
|
} else {
|
||||||
|
Log.d("CallManager") { "onPeerHangup: ${leavingPeer.take(8)} left, remaining=${connectedRemaining.map { it.take(8) }}, pending=${pendingRemaining.map { it.take(8) }}" }
|
||||||
_state.value =
|
_state.value =
|
||||||
current.copy(
|
current.copy(
|
||||||
peerPubKeys = connectedRemaining,
|
peerPubKeys = connectedRemaining,
|
||||||
pendingPeerPubKeys = pendingRemaining,
|
pendingPeerPubKeys = pendingRemaining,
|
||||||
)
|
)
|
||||||
|
onPeerLeft?.invoke(leavingPeer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,13 +538,16 @@ class CallManager(
|
|||||||
val connectedRemaining = current.peerPubKeys - leavingPeer
|
val connectedRemaining = current.peerPubKeys - leavingPeer
|
||||||
val pendingRemaining = current.pendingPeerPubKeys - leavingPeer
|
val pendingRemaining = current.pendingPeerPubKeys - leavingPeer
|
||||||
if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) {
|
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)
|
transitionToEnded(callId, current.peerPubKeys + current.pendingPeerPubKeys, EndReason.PEER_HANGUP)
|
||||||
} else {
|
} else {
|
||||||
|
Log.d("CallManager") { "onPeerHangup: ${leavingPeer.take(8)} left during connecting, remaining=${connectedRemaining.map { it.take(8) }}" }
|
||||||
_state.value =
|
_state.value =
|
||||||
current.copy(
|
current.copy(
|
||||||
peerPubKeys = connectedRemaining,
|
peerPubKeys = connectedRemaining,
|
||||||
pendingPeerPubKeys = pendingRemaining,
|
pendingPeerPubKeys = pendingRemaining,
|
||||||
)
|
)
|
||||||
|
onPeerLeft?.invoke(leavingPeer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,6 +558,7 @@ class CallManager(
|
|||||||
transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP)
|
transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP)
|
||||||
} else {
|
} else {
|
||||||
_state.value = current.copy(peerPubKeys = remaining)
|
_state.value = current.copy(peerPubKeys = remaining)
|
||||||
|
onPeerLeft?.invoke(leavingPeer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,6 +589,16 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
if (!processedEventIds.add(event.id)) return
|
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}" }
|
Log.d("CallManager") { "Processing signaling event kind=${event.kind} id=${event.id.take(8)} state=${_state.value::class.simpleName}" }
|
||||||
|
|
||||||
when (event) {
|
when (event) {
|
||||||
@@ -604,6 +648,7 @@ class CallManager(
|
|||||||
resetJob?.cancel()
|
resetJob?.cancel()
|
||||||
resetJob = null
|
resetJob = null
|
||||||
processedEventIds.clear()
|
processedEventIds.clear()
|
||||||
|
discoveredCalleePeers.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun transitionToEnded(
|
private fun transitionToEnded(
|
||||||
|
|||||||
Reference in New Issue
Block a user