fix: WebRTC call resource leaks, thread safety, and error handling

- Send hangup/reject to peer when WebRTC init or PeerConnection creation
  fails, so remote phone stops ringing instead of timing out after 60s
- Throw on null PeerConnection from factory to fail fast instead of
  silently no-oping all subsequent WebRTC operations
- Start foreground service during IncomingCall to protect ringtone
  playback from being killed on Android 14+
- Make cleanup() idempotent with AtomicBoolean guard to prevent double
  disposal when Ended state and ViewModel.onCleared race
- Replace mutableMapOf with ConcurrentHashMap for videoSenders accessed
  from UI and WebRTC callback threads
- Add @Volatile to peerConnection, videoPausedByProximity, and
  foregroundServiceStarted for cross-thread visibility
- Capture peerConnection into local variable in dispose() to prevent
  TOCTOU race between close() and null assignment
- Replace leaked MainScope() in CallNotificationReceiver with structured
  CoroutineScope that is cancelled after work completes
- Remove self-wraps in group answer/reject to avoid wasting bandwidth
  sending encrypted messages to ourselves
- Move startTimeout inside stateMutex in initiateCall for consistency

https://claude.ai/code/session_017HrFJNxD6zrGwiZ3s69xTh
This commit is contained in:
Claude
2026-04-10 23:55:16 +00:00
parent 6c28f9d995
commit d851168a46
4 changed files with 35 additions and 16 deletions
@@ -201,9 +201,9 @@ class CallManager(
val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer)
stateMutex.withLock {
_state.value = CallState.Offering(callId, setOf(calleePubKey), callType)
startTimeout(callId)
}
publishEvent(result.wrap)
startTimeout(callId)
Log.d("CallManager") { "initiateCall: offer published, timeout started" }
}
@@ -281,9 +281,10 @@ class CallManager(
discoveredCalleePeers.clear()
}
val allRecipients = current.groupMembers + signer.pubKey
Log.d("CallManager") { "acceptCall: publishing answer to ${allRecipients.size} recipients" }
val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer)
val allMembers = current.groupMembers + signer.pubKey
val otherMembers = allMembers - signer.pubKey
Log.d("CallManager") { "acceptCall: publishing answer to ${otherMembers.size} recipients" }
val result = factory.createGroupCallAnswer(sdpAnswer, otherMembers, current.callId, signer)
result.wraps.forEach { publishEvent(it) }
Log.d("CallManager") { "acceptCall: answer published, now in Connecting state" }
@@ -306,8 +307,8 @@ class CallManager(
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
}
val allRecipients = current.groupMembers + signer.pubKey
val result = factory.createGroupReject(allRecipients, current.callId, signer = signer)
val otherMembers = current.groupMembers - signer.pubKey
val result = factory.createGroupReject(otherMembers, current.callId, signer = signer)
result.wraps.forEach { publishEvent(it) }
}