From deecce6e7799c97dee99da212a665caf63baac4a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 00:35:59 +0000 Subject: [PATCH] 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 --- .../amethyst/service/call/CallController.kt | 8 ++++++++ .../amethyst/ui/call/CallActivity.kt | 15 +++++++++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index d0d6b08b7..0fea96b86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 152aa6c8e..a0836fa87 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -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() } }