fix(call): release WebRTC resources synchronously on CallActivity teardown

The normal cleanup path routes through CallManager -> state=Ended -> the
state-collector in viewModelScope -> CallController.cleanup(). If the
Activity is destroyed before the collector finishes (task removal, PiP
dismissal, system kill), the async path can leave PeerConnections, the
camera capturer, the proximity wake lock, and the audio-mode change
alive until AccountViewModel.onCleared runs later.

CallActivity.onDestroy / onStop now call CallController.cleanup()
directly as a synchronous safety net, in addition to publishing the
hangup/reject signaling on a detached scope.

To make that safe, CallController.cleanup() is now truly idempotent
within a call session: the cleanedUp flag stays sticky once set and is
re-armed only when a new call starts via initiateGroupCall() or
acceptIncomingCall(). This prevents a second sequential cleanup() call
from double-disposing WebRTC objects when both the state-collector path
and the Activity safety net run.

https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB
This commit is contained in:
Claude
2026-04-14 22:16:17 +00:00
parent 1dc6c2d0d8
commit 0fbe61274a
2 changed files with 50 additions and 9 deletions
@@ -212,6 +212,9 @@ class CallController(
val callId = UUID.randomUUID().toString()
_errorMessage.value = null
// A new call session begins — arm cleanup() so it will run again
// for this session's Ended transition.
cleanedUp.set(false)
applyCallSettings()
try {
withContext(Dispatchers.IO) { mediaManager.initialize(callType) }
@@ -258,6 +261,9 @@ class CallController(
scope.launch {
_errorMessage.value = null
// A new call session begins — arm cleanup() so it will run again
// for this session's Ended transition.
cleanedUp.set(false)
applyCallSettings()
try {
withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) }
@@ -612,6 +618,17 @@ class CallController(
// ---- Cleanup ----
/**
* Releases all WebRTC, audio, and foreground-service resources for the
* current call session.
*
* Idempotent within a call session: calling this multiple times in a row
* (e.g. once from the Ended state collector and once from [CallActivity]'s
* onDestroy safety net) executes the body at most once. The guard is
* re-armed when a new call starts via [initiateGroupCall] or
* [acceptIncomingCall], so subsequent call sessions still clean up
* correctly.
*/
fun cleanup() {
if (!cleanedUp.compareAndSet(false, true)) return
unregisterNetworkCallback()
@@ -643,7 +660,10 @@ class CallController(
videoPausedByProximity = false
videoSenders.clear()
pendingRenegotiation.clear()
cleanedUp.set(false)
// NOTE: cleanedUp intentionally stays true here so that a second,
// sequential cleanup() in the same call session is a no-op. The flag
// is re-armed in initiateGroupCall() / acceptIncomingCall() when a new
// call session begins.
}
// ---- Foreground service ----
@@ -202,28 +202,40 @@ class CallActivity : AppCompatActivity() {
if (wasInPipMode && !isInPictureInPictureMode) {
hangupInitiated = true
val manager = CallSessionBridge.callManager
val controller = CallSessionBridge.callController
val state = manager?.state?.value
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
// Publish the hangup signaling event on a detached scope so
// it survives activity teardown.
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.hangup()
finishAndRemoveTask()
}
} else {
finishAndRemoveTask()
}
// Synchronously release all WebRTC/audio resources before the
// activity is torn down so we don't leak a PeerConnection,
// camera capturer, wake lock, or audio mode change. cleanup() is
// idempotent within a call session, so the state-collector path
// triggered by hangup() above will be a no-op.
controller?.cleanup()
finishAndRemoveTask()
}
}
override fun onDestroy() {
unregisterPipReceiver()
val manager = CallSessionBridge.callManager
val controller = CallSessionBridge.callController
// Safety net: if the Activity is destroyed while a call is still
// ringing/offering, ensure the call is hung up so audio stops.
// Skip if onStop already initiated the hangup to avoid double signaling.
if (!hangupInitiated) {
val manager = CallSessionBridge.callManager
when (manager?.state?.value) {
// ringing/offering, publish the reject/hangup signaling so the other
// side stops ringing. Skip if onStop already initiated the hangup to
// avoid double signaling.
if (!hangupInitiated && manager != null) {
when (manager.state.value) {
is CallState.IncomingCall -> {
// Publish on a detached scope so the reject event goes out
// even after the activity is gone.
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.rejectCall()
}
@@ -242,6 +254,15 @@ class CallActivity : AppCompatActivity() {
}
}
// Ultimate safety net: synchronously release all WebRTC/audio
// resources before the Activity reference goes away. The normal
// Ended → state-collector → cleanup() path runs asynchronously on
// viewModelScope and is not guaranteed to finish before this method
// returns. CallController.cleanup() is idempotent within a call
// session, so calling it here in addition to the state-collector path
// is safe.
controller?.cleanup()
super.onDestroy()
}