fix: critical lifecycle bugs found during code review

1. onStop() no longer hangs up call when user switches apps
   - Added wasInPipMode flag to track if the activity was ever in PiP
   - onStop only hangs up + finishes when PiP was dismissed (swiped away),
     not when the user simply presses Home from the full-screen call UI
   - Pressing Home from full-screen enters PiP via onUserLeaveHint

2. Removed triple-hangup from overlapping lifecycle methods
   - onPictureInPictureModeChanged now only updates UI state
   - onStop handles PiP dismissal only
   - onDestroy only cleans up the PiP receiver (hangup already handled
     by CallController's state collector on Ended)

3. CallNotificationReceiver guards against stale notifications
   - Returns early if callManager/callController are null
   - Only launches CallActivity if the call is still IncomingCall
   - Cancels notification before any other action to prevent re-taps

https://claude.ai/code/session_01Ak5tTkujpjNG1r5ASuPipZ
This commit is contained in:
Claude
2026-04-02 20:55:00 +00:00
parent 724fd10405
commit 80151965c7
2 changed files with 26 additions and 28 deletions
@@ -46,6 +46,10 @@ import kotlinx.coroutines.launch
class CallActivity : AppCompatActivity() {
val isInPipMode = mutableStateOf(false)
// Tracks whether we entered PiP at least once, so we can distinguish
// "user swiped PiP away" from "user pressed Home from full-screen call".
private var wasInPipMode = false
private val pipActionReceiver =
object : BroadcastReceiver() {
@OptIn(DelicateCoroutinesApi::class)
@@ -112,26 +116,24 @@ class CallActivity : AppCompatActivity() {
enterPipIfActive()
}
@OptIn(DelicateCoroutinesApi::class)
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
isInPipMode.value = isInPictureInPictureMode
if (!isInPictureInPictureMode) {
// User pressed the X button on PiP or expanded it.
// If the activity is finishing (user dismissed PiP), hang up.
if (isFinishing) {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
}
if (isInPictureInPictureMode) {
wasInPipMode = true
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onStop() {
super.onStop()
// When PiP is dismissed (X button), Android stops the activity.
// If we're not in PiP anymore and the activity is stopping, hang up and finish.
if (!isInPictureInPictureMode) {
// Only hang up if the user dismissed PiP (swiped it away).
// When PiP is dismissed, Android calls onStop with isInPictureInPictureMode == false
// after the activity was previously in PiP mode.
// We must NOT hang up when the user simply presses Home from the full-screen
// call UI (that enters PiP via onUserLeaveHint instead).
if (wasInPipMode && !isInPictureInPictureMode) {
val state = ActiveCallHolder.callManager?.state?.value
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
@@ -140,17 +142,8 @@ class CallActivity : AppCompatActivity() {
}
}
@OptIn(DelicateCoroutinesApi::class)
override fun onDestroy() {
unregisterPipReceiver()
// If the activity is being destroyed while still in an active call
// (e.g. user swiped PiP away), hang up the call.
val state = ActiveCallHolder.callManager?.state?.value
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
GlobalScope.launch { ActiveCallHolder.callManager?.hangup() }
}
super.onDestroy()
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.call
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
@@ -36,22 +37,26 @@ class CallNotificationReceiver : BroadcastReceiver() {
) {
when (intent.action) {
ACTION_ACCEPT_CALL -> {
NotificationUtils.cancelCallNotification(context)
val callController = ActiveCallHolder.callController
val callManager = ActiveCallHolder.callManager
val state = callManager?.state?.value
if (state is com.vitorpamplona.amethyst.commons.call.CallState.IncomingCall) {
callController?.acceptIncomingCall(state.sdpOffer)
if (callController == null || callManager == null) return
val state = callManager.state.value
if (state is CallState.IncomingCall) {
callController.acceptIncomingCall(state.sdpOffer)
CallActivity.launch(context)
}
NotificationUtils.cancelCallNotification(context)
CallActivity.launch(context)
}
ACTION_REJECT_CALL -> {
val callManager = ActiveCallHolder.callManager
GlobalScope.launch {
callManager?.rejectCall()
}
NotificationUtils.cancelCallNotification(context)
val callManager = ActiveCallHolder.callManager ?: return
GlobalScope.launch {
callManager.rejectCall()
}
}
}
}