fix: additional WebRTC call hardening

- CallActivity.onDestroy: use standalone CoroutineScope instead of
  lifecycleScope which is cancelled during super.onDestroy(), ensuring
  hangup/reject signaling events are reliably published
- disposePeerSession: remove videoSenders entry for the departing peer
  to prevent stale RtpSender references leaking after PeerConnection
  disposal
- initiateGroupCall: detect when all PeerConnection creations fail and
  hang up immediately instead of leaving the call in Offering state
  until the 60-second timeout

https://claude.ai/code/session_017HrFJNxD6zrGwiZ3s69xTh
This commit is contained in:
Claude
2026-04-11 00:35:59 +00:00
parent d851168a46
commit deecce6e77
2 changed files with 17 additions and 6 deletions
@@ -220,6 +220,7 @@ class CallController(
callManager.beginOffering(callId, peerPubKeys, callType)
var successCount = 0
for (peerPubKey in peerPubKeys) {
try {
val webRtcSession = withContext(Dispatchers.IO) { createWebRtcSession(peerPubKey) }
@@ -230,10 +231,16 @@ class CallController(
callManager.publishOfferToPeer(peerPubKey, peerPubKeys, callType, callId, sdp.description)
}
}
successCount++
} catch (e: Exception) {
Log.e(TAG, "Failed to create PeerConnection for ${peerPubKey.take(8)}", e)
}
}
if (successCount == 0) {
Log.e(TAG, "All PeerConnection creations failed, hanging up")
_errorMessage.value = "Failed to start call: could not create any connections"
callManager.hangup()
}
}
}
@@ -576,6 +583,7 @@ class CallController(
// ---- Per-peer cleanup ----
fun disposePeerSession(peerPubKey: HexKey) {
videoSenders.remove(peerPubKey)
val entry = peerSessionMgr.removeSession(peerPubKey)
if (entry != null) {
try {
@@ -46,7 +46,10 @@ import com.vitorpamplona.amethyst.ui.StringResSetup
import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -213,13 +216,13 @@ class CallActivity : AppCompatActivity() {
// Safety net: if the Activity is destroyed while a call is still
// ringing/offering, ensure the call is hung up so audio stops.
// Use NonCancellable so the signaling event is published even
// though the lifecycle scope is being cancelled.
// Use a standalone CoroutineScope because lifecycleScope is cancelled
// during super.onDestroy() and may drop suspend work.
val manager = CallSessionBridge.callManager
when (manager?.state?.value) {
is CallState.IncomingCall -> {
lifecycleScope.launch {
withContext(NonCancellable) { manager.rejectCall() }
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.rejectCall()
}
}
@@ -227,8 +230,8 @@ class CallActivity : AppCompatActivity() {
is CallState.Connecting,
is CallState.Connected,
-> {
lifecycleScope.launch {
withContext(NonCancellable) { manager.hangup() }
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.hangup()
}
}