mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
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:
@@ -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
|
||||
|
||||
+55
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user