fix: move hangup signaling from fire-and-forget scope to CallForegroundService

The previous approach used a detached CoroutineScope in
CallActivity.onDestroy to publish hangup/reject events. This was
unreliable: the process could be killed before the coroutine completed,
leaving the remote peer's phone ringing.

Fix: CallForegroundService now owns hangup signaling via two paths:

1. onTaskRemoved() — fires when user swipes app from Recents. Uses
   runBlocking with 3-second timeout to synchronously publish hangup
   before the service is killed.

2. onDestroy() — fires when the service is stopped for any other
   reason. Same runBlocking pattern as a safety net.

Changes:
- AndroidManifest: stopWithTask="false" so onTaskRemoved fires instead
  of the service being silently killed.
- CallForegroundService: added onTaskRemoved(), onDestroy(), and
  publishHangupBlocking() helper.
- CallActivity.onDestroy: removed fire-and-forget CoroutineScope.
  Session resource cleanup (close()) still happens here; signaling
  hangup is delegated to the foreground service.
- CallSessionBridge.clear(): converted fire-and-forget hangup to
  runBlocking with 3-second timeout for account-switch safety.

The three-layer hangup guarantee is now:
1. CallForegroundService.onTaskRemoved/onDestroy — synchronous,
   reliable (runBlocking).
2. CallManager ringing watchdog (65s) — if nothing else fires.
3. Remote peer's own timeout — last resort.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
This commit is contained in:
Claude
2026-04-16 18:56:46 +00:00
parent ac6d8eb417
commit 89cf5e14d4
4 changed files with 73 additions and 41 deletions
+1 -1
View File
@@ -250,7 +250,7 @@
<service
android:name=".service.call.CallForegroundService"
android:foregroundServiceType="microphone|camera|phoneCall"
android:stopWithTask="true"
android:stopWithTask="false"
android:exported="false" />
<provider
@@ -35,8 +35,11 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
private const val TAG = "CallForegroundService"
@@ -125,6 +128,58 @@ class CallForegroundService : Service() {
return START_NOT_STICKY
}
/**
* Called when the user swipes the app's task away from Recents. The
* manifest declares `stopWithTask="false"` so this callback fires
* BEFORE the service is killed, giving us a window to publish the
* hangup/reject signaling event synchronously.
*
* `runBlocking` is safe here because `onTaskRemoved` runs on the
* main thread with a 5-second ANR window and the network publish
* (via Nostr relay WebSocket) completes well within that. We cap
* it at 3 seconds as a safety net.
*/
override fun onTaskRemoved(rootIntent: Intent?) {
publishHangupBlocking()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
// If onTaskRemoved already published the hangup, this is a no-op
// because CallManager.hangup() transitions to Ended and a second
// hangup() from Ended state returns immediately.
publishHangupBlocking()
super.onDestroy()
}
/**
* Synchronously publishes a hangup or reject event if a call is
* active. Uses [runBlocking] with a 3-second timeout so we never
* block the main thread past the ANR window.
*/
private fun publishHangupBlocking() {
val manager = CallSessionBridge.callManager ?: return
val state = manager.state.value
try {
runBlocking {
withTimeoutOrNull(3_000L) {
when (state) {
is CallState.IncomingCall -> manager.rejectCall()
is CallState.Offering,
is CallState.Connecting,
is CallState.Connected,
-> manager.hangup()
else -> { /* nothing to do */ }
}
}
}
} catch (e: Exception) {
Log.e(TAG, "publishHangupBlocking failed", e)
}
}
private fun createNotificationChannel() {
val channel =
NotificationChannel(
@@ -23,10 +23,9 @@ package com.vitorpamplona.amethyst.service.call
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
/**
* Process-level singleton that bridges the active [CallManager] and
@@ -67,8 +66,12 @@ object CallSessionBridge {
state is CallState.Connecting ||
state is CallState.Connected
) {
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
mgr.hangup()
try {
runBlocking {
withTimeoutOrNull(3_000L) { mgr.hangup() }
}
} catch (e: Exception) {
Log.e("CallSessionBridge", "clear: hangup failed", e)
}
}
}
@@ -49,9 +49,6 @@ import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class CallActivity : AppCompatActivity() {
@@ -228,40 +225,17 @@ class CallActivity : AppCompatActivity() {
override fun onDestroy() {
unregisterPipReceiver()
val manager = CallSessionBridge.callManager
// Release all WebRTC/audio/notification resources FIRST, before
// the signaling hangup. close() is synchronous and runs before
// super.onDestroy() cancels lifecycleScope, so the init
// collectors are still alive but close() gets to run first.
// Release all WebRTC/audio/notification resources. close() is
// synchronous and runs before super.onDestroy() cancels
// lifecycleScope.
session?.close()
session = null
// No callback nulling needed — CallSession collects from
// SharedFlows; the `closed` flag prevents processing after close().
// Publish reject/hangup so the remote side stops ringing.
// Use goAsync-style: the hangup is best-effort. If the process
// dies before it completes, the watchdog on the remote side
// (or our own CallManager watchdog) handles cleanup.
if (manager != null) {
when (manager.state.value) {
is CallState.IncomingCall -> {
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.rejectCall()
}
}
is CallState.Offering,
is CallState.Connecting,
is CallState.Connected,
-> {
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.hangup()
}
}
else -> {}
}
}
// Hangup signaling is NOT published here. It is handled by
// CallForegroundService.onTaskRemoved() / onDestroy() which
// runs synchronously via runBlocking with a 3-second timeout.
// This is more reliable than the previous fire-and-forget
// CoroutineScope that could be killed before completing.
super.onDestroy()
}