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 50913d770..b74f5ff9c 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 @@ -134,6 +134,15 @@ class CallController( callManager.state.collect { state -> when (state) { is CallState.IncomingCall -> { + // A new call session begins — arm cleanup() so it will + // run for this session's Ended transition. This MUST + // happen for every incoming call (even ones the user + // ends up rejecting), otherwise a stale cleanedUp flag + // from the previous call session would turn cleanup() + // into a no-op and leave the ringtone/notification + // playing forever. See the bug where rejecting a + // second consecutive call required killing the app. + cleanedUp.set(false) ensureForegroundService() withContext(Dispatchers.IO) { audioManager.startRinging() } scope.launch { @@ -142,6 +151,10 @@ class CallController( } is CallState.Offering -> { + // Same rationale as IncomingCall above: ensure cleanup + // can run for this session even if the previous one + // already ran cleanup(). + cleanedUp.set(false) audioManager.startRingbackTone() ensureForegroundService() } @@ -642,14 +655,46 @@ class CallController( * 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. + * Stopping the ringtone, ringback tone, and incoming-call notification + * runs UNCONDITIONALLY on every invocation — even if the heavy teardown + * has already been done. These three operations are idempotent and must + * never be skipped, otherwise a stale [cleanedUp] flag could leave the + * ringtone playing and the notification stuck on screen until the app is + * force-killed. + * + * The remainder (peer-session disposal, media manager, network callbacks, + * etc.) is guarded by [cleanedUp] and runs at most once per call session. + * The guard is re-armed when a new call starts: outgoing calls re-arm in + * [initiateGroupCall], accepted incoming calls in [acceptIncomingCall], + * and any new IncomingCall/Offering state transition in the state + * collector. This last path is critical for incoming calls the user + * rejects (or that the remote party hangs up), since neither + * [initiateGroupCall] nor [acceptIncomingCall] is invoked for them. */ fun cleanup() { + // Stop the ringtone, ringback tone, and incoming-call notification + // UNCONDITIONALLY — these are idempotent and absolutely critical for + // the user experience. They must run on every cleanup() call, even if + // the heavy resource teardown below is already complete (cleanedUp == + // true). Otherwise an unexpected double-cleanup or a stale cleanedUp + // flag from a previous session could leave the ringtone playing and + // the notification stuck on screen until the app is force-killed. + try { + audioManager.stopRinging() + } catch (e: Exception) { + Log.e(TAG, "cleanup: audioManager.stopRinging() failed", e) + } + try { + audioManager.stopRingbackTone() + } catch (e: Exception) { + Log.e(TAG, "cleanup: audioManager.stopRingbackTone() failed", e) + } + try { + NotificationUtils.cancelCallNotification(context) + } catch (e: Exception) { + Log.e(TAG, "cleanup: cancelCallNotification() failed", e) + } + if (!cleanedUp.compareAndSet(false, true)) return unregisterNetworkCallback() try { @@ -663,7 +708,6 @@ class CallController( Log.e(TAG, "cleanup: stopForegroundService() failed", e) } foregroundServiceStarted = false - NotificationUtils.cancelCallNotification(context) videoMonitor.dispose() @@ -681,9 +725,11 @@ class CallController( videoSenders.clear() pendingRenegotiation.clear() // 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. + // sequential cleanup() in the same call session is a no-op for the + // heavy teardown above. The flag is re-armed when a new call session + // begins, in initiateGroupCall() / acceptIncomingCall() AND in the + // state collector for IncomingCall / Offering transitions, so + // subsequent sessions still clean up correctly. } // ---- Foreground service ---- 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 e6560b7bd..e9346272c 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 @@ -263,6 +263,28 @@ class CallActivity : AppCompatActivity() { // is safe. controller?.cleanup() + // Hard guarantee: the ringtone, ringback tone, and incoming-call + // notification are tied to this Activity's lifecycle. When the + // Activity is destroyed for ANY reason (user reject, hangup, finish, + // process recreation, etc.), they MUST be gone — even if the + // CallController is missing, its cleanup guard is stale, or the + // state-collector hasn't fired yet. We had a bug where rejecting a + // call left the ringtone playing until the user killed the entire + // app from settings; this block is the ultimate stop signal. + try { + controller?.audioManager?.stopRinging() + } catch (_: Exception) { + } + try { + controller?.audioManager?.stopRingbackTone() + } catch (_: Exception) { + } + try { + com.vitorpamplona.amethyst.service.notifications.NotificationUtils + .cancelCallNotification(this) + } catch (_: Exception) { + } + super.onDestroy() }