Merge pull request #2407 from vitorpamplona/claude/fix-call-rejection-audio-Ty3MU

Fix ringtone/notification stuck after rejecting consecutive calls
This commit is contained in:
Vitor Pamplona
2026-04-15 15:01:01 -04:00
committed by GitHub
2 changed files with 78 additions and 10 deletions
@@ -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 ----
@@ -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()
}