fix: harden call cleanup to guarantee nothing is left running

Three issues addressed:

1. Make cleanup() exception-safe: wrap each WebRTC dispose call in
   its own try-catch so that a failure in one (e.g. native crash
   disposing a PeerConnection) does not skip releasing the camera,
   audio mode, foreground service, or EGL context.

2. Upgrade the Idle safety net to call cleanup() instead of only
   stopping ringing/notifications. If the Ended state is missed due
   to StateFlow conflation, the Idle handler now performs full
   resource cleanup (camera, WebRTC, audio mode, foreground service,
   proximity wake lock). cleanup() is idempotent since all resources
   are null-checked and nulled out.

3. Call cleanup() in AccountViewModel.onCleared() so that WebRTC
   resources, camera, audio mode, and foreground service are released
   when the ViewModel is destroyed (e.g. logout, activity recreation).

4. Clear processedEventIds when transitioning to Idle to prevent
   unbounded memory growth across calls.

https://claude.ai/code/session_01GWRdrVAa29BsDkv8R7Z3Y9
This commit is contained in:
Claude
2026-04-03 18:16:09 +00:00
parent 286685c0bd
commit fe13b77e22
3 changed files with 65 additions and 17 deletions
@@ -193,12 +193,11 @@ class CallController(
} }
is CallState.Idle -> { is CallState.Idle -> {
// Safety net: ensure ringing and notifications are // Safety net: full cleanup in case the Ended state
// stopped even if the Ended state was missed due to // was missed due to StateFlow conflation. cleanup()
// StateFlow conflation. // is idempotent — calling it twice is harmless because
audioManager.stopRinging() // each resource is null-checked and nulled out.
audioManager.stopRingbackTone() cleanup()
NotificationUtils.cancelCallNotification(context)
} }
} }
} }
@@ -737,24 +736,71 @@ class CallController(
// ---- Cleanup ---- // ---- Cleanup ----
fun cleanup() { fun cleanup() {
audioManager.release() // Each block is wrapped individually so that a failure in one
stopForegroundService() // (e.g. a WebRTC native crash) does not prevent the rest from
// running. Without this, a single exception could leave the
// camera open, audio mode stuck, or the foreground service alive.
try {
audioManager.release()
} catch (e: Exception) {
Log.e(TAG, "cleanup: audioManager.release() failed", e)
}
try {
stopForegroundService()
} catch (e: Exception) {
Log.e(TAG, "cleanup: stopForegroundService() failed", e)
}
foregroundServiceStarted = false foregroundServiceStarted = false
NotificationUtils.cancelCallNotification(context) NotificationUtils.cancelCallNotification(context)
stopRemoteVideoMonitor() stopRemoteVideoMonitor()
// Dispose all peer sessions // Dispose all peer sessions
peerSessions.values.forEach { it.session.dispose() } for (ps in peerSessions.values) {
try {
ps.session.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: PeerSession.dispose() failed", e)
}
}
peerSessions.clear() peerSessions.clear()
// Dispose shared resources // Dispose shared resources — each in its own try-catch so one
stopCamera() // failure does not prevent the others from being released.
localAudioTrackInternal?.dispose() try {
localVideoTrackInternal?.dispose() stopCamera()
localAudioSource?.dispose() } catch (e: Exception) {
localVideoSource?.dispose() Log.e(TAG, "cleanup: stopCamera() failed", e)
peerConnectionFactory?.dispose() }
sharedEglBase?.release() try {
localAudioTrackInternal?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: localAudioTrack.dispose() failed", e)
}
try {
localVideoTrackInternal?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: localVideoTrack.dispose() failed", e)
}
try {
localAudioSource?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: localAudioSource.dispose() failed", e)
}
try {
localVideoSource?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: localVideoSource.dispose() failed", e)
}
try {
peerConnectionFactory?.dispose()
} catch (e: Exception) {
Log.e(TAG, "cleanup: peerConnectionFactory.dispose() failed", e)
}
try {
sharedEglBase?.release()
} catch (e: Exception) {
Log.e(TAG, "cleanup: sharedEglBase.release() failed", e)
}
localAudioTrackInternal = null localAudioTrackInternal = null
localVideoTrackInternal = null localVideoTrackInternal = null
@@ -1429,6 +1429,7 @@ class AccountViewModel(
override fun onCleared() { override fun onCleared() {
Log.d("AccountViewModel", "onCleared") Log.d("AccountViewModel", "onCleared")
callController?.cleanup()
feedStates.destroy() feedStates.destroy()
super.onCleared() super.onCleared()
} }
@@ -606,6 +606,7 @@ class CallManager(
delay(ENDED_DISPLAY_MS) delay(ENDED_DISPLAY_MS)
if (_state.value is CallState.Ended) { if (_state.value is CallState.Ended) {
_state.value = CallState.Idle _state.value = CallState.Idle
processedEventIds.clear()
} }
} }
} }