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:
@@ -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
|
||||||
|
|||||||
+1
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user