diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt index 0879145506..1823bfeb04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallAudioManager.kt @@ -107,6 +107,7 @@ class CallAudioManager( } fun startRingbackTone() { + stopRingbackTone() try { ringbackTone = ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80).also { @@ -123,6 +124,7 @@ class CallAudioManager( } fun switchToCallAudioMode() { + if (audioManager.mode == AudioManager.MODE_IN_COMMUNICATION) return previousAudioMode = audioManager.mode audioManager.mode = AudioManager.MODE_IN_COMMUNICATION 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 c1f63332f4..7788e9a39e 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 @@ -177,7 +177,15 @@ class CallController( } is CallState.Connected -> { + // Stop ringing/ringback in case the Connecting state + // was skipped due to StateFlow conflation (the value + // can change Offering → Connecting → Connected before + // the collector processes Connecting). + audioManager.stopRinging() + audioManager.stopRingbackTone() + withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } audioManager.acquireProximityWakeLock() + NotificationUtils.cancelCallNotification(context) } is CallState.Ended -> { @@ -185,12 +193,11 @@ class CallController( } is CallState.Idle -> { - // Safety net: ensure ringing and notifications are - // stopped even if the Ended state was missed due to - // StateFlow conflation. - audioManager.stopRinging() - audioManager.stopRingbackTone() - NotificationUtils.cancelCallNotification(context) + // Safety net: full cleanup in case the Ended state + // was missed due to StateFlow conflation. cleanup() + // is idempotent — calling it twice is harmless because + // each resource is null-checked and nulled out. + cleanup() } } } @@ -729,24 +736,71 @@ class CallController( // ---- Cleanup ---- fun cleanup() { - audioManager.release() - stopForegroundService() + // Each block is wrapped individually so that a failure in one + // (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 NotificationUtils.cancelCallNotification(context) stopRemoteVideoMonitor() // 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() - // Dispose shared resources - stopCamera() - localAudioTrackInternal?.dispose() - localVideoTrackInternal?.dispose() - localAudioSource?.dispose() - localVideoSource?.dispose() - peerConnectionFactory?.dispose() - sharedEglBase?.release() + // Dispose shared resources — each in its own try-catch so one + // failure does not prevent the others from being released. + try { + stopCamera() + } catch (e: Exception) { + Log.e(TAG, "cleanup: stopCamera() failed", e) + } + 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 localVideoTrackInternal = null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 0227d79eee..73c0f12f80 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -1429,6 +1429,7 @@ class AccountViewModel( override fun onCleared() { Log.d("AccountViewModel", "onCleared") + callController?.cleanup() feedStates.destroy() super.onCleared() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index faac810c47..a872d5230a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -606,6 +606,7 @@ class CallManager( delay(ENDED_DISPLAY_MS) if (_state.value is CallState.Ended) { _state.value = CallState.Idle + processedEventIds.clear() } } }