fix: CallActivity owns the entire call session lifecycle

Root cause: WebRTC call ringing continued forever after cancellation
because ringing, WebRTC, and notifications were owned by CallController
which lived in viewModelScope (account lifetime), not by CallActivity.
Cleanup depended on a state collector observing Ended in time, which
raced, dropped, and went stale.

Architecture after fix:
- CallManager (background, AccountViewModel scope): state machine only.
  No audio, no WebRTC, no notifications. Exposes state as StateFlow,
  renegotiation events as SharedFlow, and a 65-second ringing watchdog
  as a fail-safe.
- CallSession (Activity-scoped, replaces CallController): owns all call
  resources. Created in onCreate, destroyed via close() in onDestroy.
  Implements AutoCloseable. No cleanedUp guard flag needed.
- CallSessionBridge: slimmed down — holds only callManager +
  accountViewModel for cross-Activity reference sharing. No
  callController field.

Key changes:
- CallController renamed to CallSession, moved to ui/call/session/.
- CallSession.close() replaces cleanup() — single-shot, no guard flags.
- CallActivity creates and owns CallSession directly.
- CallManager.onRenegotiationOfferReceived callback replaced with
  renegotiationEvents SharedFlow (buffered, survives Activity restarts).
- CallManager gains ringing watchdog (65s) that forces Ended(TIMEOUT)
  if state stays in IncomingCall/Offering past the deadline.
- CallNotifier extracted from NotificationUtils into dedicated class.
- AccountViewModel.initCallController deleted; callManager wired to
  newNotesPreProcessor eagerly in init block.
- ChatroomScreen uses CallActivity.launchForOutgoingCall() with intent
  extras instead of directly calling callController.initiateGroupCall().
- AndroidManifest adds navigation|fontScale|density to configChanges
  so no config change recreates CallActivity mid-call.

Invariants:
1. CallActivity.onDestroy → session.close() → all resources released.
2. Ringing cannot outlive any code path: Activity close, state
   collector, and 65s watchdog provide three independent stop paths.
3. Config changes cannot kill the call (manifest prevents recreation).
4. No global mutable singleton for CallController.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
This commit is contained in:
Claude
2026-04-16 05:44:52 +00:00
parent 8466a0c0d5
commit 3cddda6ef9
15 changed files with 525 additions and 429 deletions
+1 -1
View File
@@ -214,7 +214,7 @@
<activity
android:name=".ui.call.CallActivity"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard|uiMode"
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:supportsPictureInPicture="true"
android:launchMode="singleTop"
android:exported="false"
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.call
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@@ -48,7 +48,7 @@ class CallNotificationReceiver : BroadcastReceiver() {
try {
when (intent.action) {
ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context)
CallNotifier.cancelIncomingCall(context)
callManager.rejectCall()
}
@@ -24,31 +24,31 @@ import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Process-level singleton that bridges the active call state between
* the main activity (which owns [AccountViewModel]) and [com.vitorpamplona.amethyst.ui.call.CallActivity]
* (which runs in its own window but in the same process).
* Process-level singleton that bridges the active [CallManager] and
* [AccountViewModel] between the main activity and
* [com.vitorpamplona.amethyst.ui.call.CallActivity] (which runs in its
* own window but in the same process).
*
* No call controller / session is held here — each [CallActivity]
* creates and owns its own [com.vitorpamplona.amethyst.ui.call.session.CallSession]
* whose lifetime is tied to the Activity's lifecycle.
*/
object CallSessionBridge {
var callManager: CallManager? = null
private set
var callController: CallController? = null
private set
var accountViewModel: AccountViewModel? = null
private set
fun set(
callManager: CallManager,
callController: CallController?,
accountViewModel: AccountViewModel,
) {
this.callManager = callManager
this.callController = callController
this.accountViewModel = accountViewModel
}
fun clear() {
callManager = null
callController = null
accountViewModel = null
}
}
@@ -0,0 +1,213 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call.notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import androidx.core.app.NotificationCompat
import coil3.asDrawable
import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.call.CallNotificationReceiver
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Focused helper for incoming-call notification posting and lifecycle.
*
* Owns the "CALL_CHANNEL" NotificationChannel and the single
* incoming-call system notification. Intentionally decoupled from the
* generic notification helper (`NotificationUtils`) so that call UI
* lifecycle concerns stay in one place and callers do not have to pull
* in the whole DM/zap/reaction notification subsystem just to post a
* ring notification.
*
* Invoked from:
* - [CallActivity]'s owning `CallSession` to cancel the incoming-call
* notification once the call transitions out of the ringing state.
* - `EventNotificationConsumer` to post the full-screen incoming-call
* notification when a CallOffer event arrives while the app is in
* the background.
*/
object CallNotifier {
private var callChannel: NotificationChannel? = null
private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
private const val CALL_NOTIFICATION_ID = 0x50000
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
callChannel?.let { return it }
val channel =
NotificationChannel(
CALL_CHANNEL_ID,
stringRes(applicationContext, R.string.app_notification_calls_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
// Silence the notification sound — CallAudioManager plays the ringtone
setSound(null, null)
enableVibration(false)
}
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
callChannel = channel
return channel
}
/**
* Posts the system incoming-call notification. The notification is a
* full-screen intent that opens [CallActivity] when the device is
* unlocked, and provides Accept / Reject actions.
*/
fun send(
callerName: String,
callerBitmap: Bitmap?,
applicationContext: Context,
) {
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = getOrCreateCallChannel(applicationContext)
// Tapping the notification opens the CallActivity
val contentIntent =
Intent(applicationContext, CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val contentPendingIntent =
PendingIntent.getActivity(
applicationContext,
CALL_NOTIFICATION_ID,
contentIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// Accept launches CallActivity directly (not via BroadcastReceiver)
// to comply with Android 12+ notification trampoline restrictions.
val acceptIntent =
Intent(applicationContext, CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(CallActivity.EXTRA_ACCEPT_CALL, true)
}
val acceptPendingIntent =
PendingIntent.getActivity(
applicationContext,
CALL_NOTIFICATION_ID + 1,
acceptIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val rejectIntent =
Intent(applicationContext, CallNotificationReceiver::class.java).apply {
action = CallNotificationReceiver.ACTION_REJECT_CALL
}
val rejectPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
CALL_NOTIFICATION_ID + 2,
rejectIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val builder =
NotificationCompat
.Builder(applicationContext, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(applicationContext, R.string.call_incoming))
.setContentText(callerName)
.setLargeIcon(callerBitmap)
.setContentIntent(contentPendingIntent)
.setFullScreenIntent(contentPendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent)
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
}
/**
* Loads the caller's display name + profile picture and posts the
* incoming-call notification. Invoked from the `CallSession` state
* collector when the call manager transitions to `IncomingCall`.
*/
suspend fun showIncomingCall(
callerPubKey: String,
context: Context,
) {
val callerUser = LocalCache.getOrCreateUser(callerPubKey)
val callerName = callerUser.toBestDisplayName()
@Suppress("UNUSED_VARIABLE")
val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}"
val callerBitmap =
callerUser.profilePicture()?.let { pictureUrl ->
withContext(Dispatchers.IO) {
try {
val request =
ImageRequest
.Builder(context)
.data(pictureUrl)
.allowHardware(false)
.build()
val result = coil3.ImageLoader(context).execute(request)
(result.image?.asDrawable(context.resources) as? BitmapDrawable)?.bitmap
} catch (_: Exception) {
null
}
}
}
send(
callerName = callerName,
callerBitmap = callerBitmap,
applicationContext = context,
)
}
/** Cancels the incoming-call notification, if shown. */
fun cancelIncomingCall(applicationContext: Context) {
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel("call", CALL_NOTIFICATION_ID)
}
}
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
@@ -810,13 +811,11 @@ class EventNotificationConsumer(
}
}
NotificationUtils
.sendCallNotification(
callerName = callerName,
callerBitmap = callerBitmap,
uri = "nostr:${event.pubKey.hexToByteArray().toNpub()}",
applicationContext = applicationContext,
)
CallNotifier.send(
callerName = callerName,
callerBitmap = callerBitmap,
applicationContext = applicationContext,
)
}
fun notificationManager(): NotificationManager =
@@ -38,11 +38,8 @@ import coil3.asDrawable
import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -51,14 +48,11 @@ object NotificationUtils {
private var zapChannel: NotificationChannel? = null
private var reactionChannel: NotificationChannel? = null
private var chessChannel: NotificationChannel? = null
private var callChannel: NotificationChannel? = null
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
private const val CALL_NOTIFICATION_ID = 0x50000
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
@@ -522,144 +516,6 @@ object NotificationUtils {
notify(summaryId, summaryBuilder.build())
}
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
if (callChannel != null) return callChannel!!
callChannel =
NotificationChannel(
CALL_CHANNEL_ID,
stringRes(applicationContext, R.string.app_notification_calls_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
// Silence the notification sound — CallAudioManager plays the ringtone
setSound(null, null)
enableVibration(false)
}
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(callChannel!!)
return callChannel!!
}
fun sendCallNotification(
callerName: String,
callerBitmap: Bitmap?,
uri: String,
applicationContext: Context,
) {
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = getOrCreateCallChannel(applicationContext)
// Tapping the notification opens the CallActivity
val contentIntent =
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val contentPendingIntent =
PendingIntent.getActivity(
applicationContext,
CALL_NOTIFICATION_ID,
contentIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// Accept launches CallActivity directly (not via BroadcastReceiver)
// to comply with Android 12+ notification trampoline restrictions.
val acceptIntent =
Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra(com.vitorpamplona.amethyst.ui.call.CallActivity.EXTRA_ACCEPT_CALL, true)
}
val acceptPendingIntent =
PendingIntent.getActivity(
applicationContext,
CALL_NOTIFICATION_ID + 1,
acceptIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val rejectIntent =
Intent(applicationContext, com.vitorpamplona.amethyst.service.call.CallNotificationReceiver::class.java).apply {
action = com.vitorpamplona.amethyst.service.call.CallNotificationReceiver.ACTION_REJECT_CALL
}
val rejectPendingIntent =
PendingIntent.getBroadcast(
applicationContext,
CALL_NOTIFICATION_ID + 2,
rejectIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val builder =
NotificationCompat
.Builder(applicationContext, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(applicationContext, R.string.call_incoming))
.setContentText(callerName)
.setLargeIcon(callerBitmap)
.setContentIntent(contentPendingIntent)
.setFullScreenIntent(contentPendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent)
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
}
fun cancelCallNotification(applicationContext: Context) {
val notificationManager: NotificationManager =
applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel("call", CALL_NOTIFICATION_ID)
}
suspend fun showIncomingCallNotification(
callerPubKey: String,
context: Context,
) {
val callerUser = LocalCache.getOrCreateUser(callerPubKey)
val callerName = callerUser.toBestDisplayName()
val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}"
val callerBitmap =
callerUser.profilePicture()?.let { pictureUrl ->
withContext(Dispatchers.IO) {
try {
val request =
ImageRequest
.Builder(context)
.data(pictureUrl)
.allowHardware(false)
.build()
val result = coil3.ImageLoader(context).execute(request)
(result.image?.asDrawable(context.resources) as? BitmapDrawable)?.bitmap
} catch (_: Exception) {
null
}
}
}
sendCallNotification(
callerName = callerName,
callerBitmap = callerBitmap,
uri = uri,
applicationContext = context,
)
}
private fun NotificationManager.isDuplicate(notId: Int): Boolean {
val notifications: Array<StatusBarNotification> = activeNotifications
for (notification in notifications) {
@@ -40,12 +40,15 @@ import androidx.lifecycle.lifecycleScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.StringResSetup
import com.vitorpamplona.amethyst.ui.call.session.CallSession
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
@@ -54,9 +57,8 @@ 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
/** Activity-owned call session. Created in [onCreate], released in [onDestroy]. */
private var session: CallSession? = null
// Launcher for requesting call permissions when accepting from notification
private val permissionLauncher =
@@ -67,7 +69,6 @@ class CallActivity : AppCompatActivity() {
}
private var pendingAcceptIsVideo = false
private var hangupInitiated = false
private val pipActionReceiver =
object : BroadcastReceiver() {
@@ -81,7 +82,7 @@ class CallActivity : AppCompatActivity() {
}
ACTION_PIP_TOGGLE_MUTE -> {
CallSessionBridge.callController?.toggleAudioMute()
session?.toggleMute()
updatePipParams()
}
}
@@ -105,7 +106,6 @@ class CallActivity : AppCompatActivity() {
}
val callManager = CallSessionBridge.callManager
val callController = CallSessionBridge.callController
val accountViewModel = CallSessionBridge.accountViewModel
if (callManager == null || accountViewModel == null) {
@@ -113,9 +113,29 @@ class CallActivity : AppCompatActivity() {
return
}
// Create the Activity-owned call session.
val callSession =
CallSession(
context = applicationContext,
callManager = callManager,
scope = lifecycleScope,
publishWrap = { wrap -> accountViewModel.account.publishCallSignaling(wrap) },
signerProvider = { accountViewModel.account.signer },
localPubKey = accountViewModel.account.signer.pubKey,
settingsProvider = { accountViewModel.account.settings },
)
session = callSession
// Wire CallManager callbacks to this session's handlers.
callManager.onAnswerReceived = { event -> callSession.onCallAnswerReceived(event.pubKey, event.sdpAnswer()) }
callManager.onIceCandidateReceived = { event -> callSession.onIceCandidateReceived(event) }
callManager.onNewPeerInGroupCall = { peerPubKey -> callSession.onNewPeerInGroupCall(peerPubKey) }
callManager.onMidCallOfferReceived = { peerPubKey, sdpOffer -> callSession.onMidCallOfferReceived(peerPubKey, sdpOffer) }
callManager.onPeerLeft = { peerPubKey -> callSession.disposePeerSession(peerPubKey) }
registerPipReceiver()
observeVideoStateForPip(callController)
handleAcceptCallIntent(intent)
observeVideoStateForPip(callSession)
handleIntent(intent)
setContent {
AmethystTheme {
@@ -133,7 +153,7 @@ class CallActivity : AppCompatActivity() {
CallScreen(
callManager = callManager,
callController = callController,
callSession = callSession,
accountViewModel = accountViewModel,
onCallEnded = { finish() },
isInPipMode = isInPipMode.value,
@@ -144,37 +164,57 @@ class CallActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleAcceptCallIntent(intent)
handleIntent(intent)
}
private fun handleAcceptCallIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_ACCEPT_CALL, false) != true) return
// Clear the extra so it doesn't re-trigger on config changes
intent.removeExtra(EXTRA_ACCEPT_CALL)
private fun handleIntent(intent: Intent?) {
intent ?: return
com.vitorpamplona.amethyst.service.notifications.NotificationUtils
.cancelCallNotification(this)
// Accept an incoming call (from notification action)
if (intent.getBooleanExtra(EXTRA_ACCEPT_CALL, false)) {
intent.removeExtra(EXTRA_ACCEPT_CALL)
CallNotifier.cancelIncomingCall(this)
val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value
if (state !is CallState.IncomingCall) return
val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value
if (state !is CallState.IncomingCall) return
val isVideo = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
pendingAcceptIsVideo = isVideo
val isVideo = state.callType == CallType.VIDEO
pendingAcceptIsVideo = isVideo
if (hasCallPermissions(this, isVideo)) {
acceptCall()
} else {
permissionLauncher.launch(buildCallPermissions(isVideo))
if (hasCallPermissions(this, isVideo)) {
acceptCall()
} else {
permissionLauncher.launch(buildCallPermissions(isVideo))
}
return
}
// Initiate an outgoing call (from ChatroomScreen)
if (intent.getBooleanExtra(EXTRA_INITIATE_CALL, false)) {
intent.removeExtra(EXTRA_INITIATE_CALL)
val peerPubKeys = intent.getStringArrayExtra(EXTRA_PEER_PUB_KEYS)?.toSet() ?: return
val callTypeName = intent.getStringExtra(EXTRA_CALL_TYPE) ?: return
val callType = CallType.valueOf(callTypeName)
pendingAcceptIsVideo = callType == CallType.VIDEO
if (hasCallPermissions(this, callType == CallType.VIDEO)) {
session?.initiate(peerPubKeys, callType)
} else {
// Store for after permission grant — will need separate handling
permissionLauncher.launch(buildCallPermissions(callType == CallType.VIDEO))
}
return
}
}
private fun acceptCall() {
val callController = CallSessionBridge.callController ?: return
val session = session ?: return
val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value
if (state is CallState.IncomingCall) {
callController.acceptIncomingCall(state.sdpOffer)
session.accept(state.sdpOffer)
}
}
@@ -186,61 +226,20 @@ class CallActivity : AppCompatActivity() {
override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode)
isInPipMode.value = isInPictureInPictureMode
if (isInPictureInPictureMode) {
wasInPipMode = true
}
}
override fun onStop() {
super.onStop()
// 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) {
hangupInitiated = true
val manager = CallSessionBridge.callManager
val controller = CallSessionBridge.callController
val state = manager?.state?.value
if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) {
// Publish the hangup signaling event on a detached scope so
// it survives activity teardown.
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.hangup()
}
}
// Synchronously release all WebRTC/audio resources before the
// activity is torn down so we don't leak a PeerConnection,
// camera capturer, wake lock, or audio mode change. cleanup() is
// idempotent within a call session, so the state-collector path
// triggered by hangup() above will be a no-op.
controller?.cleanup()
finishAndRemoveTask()
}
}
override fun onDestroy() {
unregisterPipReceiver()
// Publish reject/hangup so the remote side stops ringing.
val manager = CallSessionBridge.callManager
val controller = CallSessionBridge.callController
// Safety net: if the Activity is destroyed while a call is still
// ringing/offering, publish the reject/hangup signaling so the other
// side stops ringing. Skip if onStop already initiated the hangup to
// avoid double signaling.
if (!hangupInitiated && manager != null) {
if (manager != null) {
when (manager.state.value) {
is CallState.IncomingCall -> {
// Publish on a detached scope so the reject event goes out
// even after the activity is gone.
CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.rejectCall()
}
}
is CallState.Offering,
is CallState.Connecting,
is CallState.Connected,
@@ -249,49 +248,22 @@ class CallActivity : AppCompatActivity() {
manager.hangup()
}
}
else -> {}
}
}
// Ultimate safety net: synchronously release all WebRTC/audio
// resources before the Activity reference goes away. The normal
// Ended → state-collector → cleanup() path runs asynchronously on
// viewModelScope and is not guaranteed to finish before this method
// returns. CallController.cleanup() is idempotent within a call
// session, so calling it here in addition to the state-collector path
// 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) {
}
// Single-shot release of all WebRTC/audio/notification resources.
// Guaranteed by Android lifecycle — when Activity dies, the call dies.
session?.close()
session = null
super.onDestroy()
}
private fun observeVideoStateForPip(controller: com.vitorpamplona.amethyst.service.call.CallController?) {
controller ?: return
private fun observeVideoStateForPip(callSession: CallSession?) {
callSession ?: return
lifecycleScope.launch {
controller.isRemoteVideoActive.collect {
callSession.isRemoteVideoActive.collect {
updatePipParams()
}
}
@@ -336,9 +308,9 @@ class CallActivity : AppCompatActivity() {
}
private fun computePipAspectRatio(): Rational {
val controller = CallSessionBridge.callController ?: return Rational(9, 16)
val isVideoActive = controller.isRemoteVideoActive.value
val videoRatio = controller.remoteVideoAspectRatio.value
val s = session ?: return Rational(9, 16)
val isVideoActive = s.isRemoteVideoActive.value
val videoRatio = s.remoteVideoAspectRatio.value
if (isVideoActive && videoRatio != null && videoRatio > 0f) {
// Clamp to Android's allowed PiP range (roughly 1:2.39 to 2.39:1)
@@ -355,7 +327,7 @@ class CallActivity : AppCompatActivity() {
val actions = mutableListOf<RemoteAction>()
// Mute / Unmute toggle
val isMuted = CallSessionBridge.callController?.isAudioMuted?.value == true
val isMuted = session?.isAudioMuted?.value == true
val muteIntent =
PendingIntent.getBroadcast(
this,
@@ -413,6 +385,9 @@ class CallActivity : AppCompatActivity() {
companion object {
const val EXTRA_ACCEPT_CALL = "com.vitorpamplona.amethyst.EXTRA_ACCEPT_CALL"
const val EXTRA_INITIATE_CALL = "com.vitorpamplona.amethyst.EXTRA_INITIATE_CALL"
const val EXTRA_PEER_PUB_KEYS = "com.vitorpamplona.amethyst.EXTRA_PEER_PUB_KEYS"
const val EXTRA_CALL_TYPE = "com.vitorpamplona.amethyst.EXTRA_CALL_TYPE"
private const val ACTION_PIP_HANGUP = "com.vitorpamplona.amethyst.PIP_HANGUP"
private const val ACTION_PIP_TOGGLE_MUTE = "com.vitorpamplona.amethyst.PIP_TOGGLE_MUTE"
private const val PIP_HANGUP_REQUEST_CODE = 0x60001
@@ -425,5 +400,20 @@ class CallActivity : AppCompatActivity() {
},
)
}
fun launchForOutgoingCall(
context: Context,
peerPubKeys: Set<String>,
callType: CallType,
) {
context.startActivity(
Intent(context, CallActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(EXTRA_INITIATE_CALL, true)
putExtra(EXTRA_PEER_PUB_KEYS, peerPubKeys.toTypedArray())
putExtra(EXTRA_CALL_TYPE, callType.name)
},
)
}
}
}
@@ -59,7 +59,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.call.session.CallSession
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay
@@ -68,7 +68,7 @@ import kotlinx.coroutines.launch
@Composable
fun CallScreen(
callManager: CallManager,
callController: CallController?,
callSession: CallSession?,
accountViewModel: AccountViewModel,
onCallEnded: () -> Unit,
isInPipMode: Boolean = false,
@@ -77,7 +77,7 @@ fun CallScreen(
val scope = rememberCoroutineScope()
val context = LocalContext.current
val emptyStringFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<String?>(null) }
val errorMessage by (callController?.errorMessage ?: emptyStringFlow).collectAsState()
val errorMessage by (callSession?.errorMessage ?: emptyStringFlow).collectAsState()
BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) {
scope.launch { callManager.hangup() }
@@ -124,7 +124,7 @@ fun CallScreen(
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
val acceptWithPermission =
rememberCallWithPermission(context, isVideo = isVideoCall) {
callController?.acceptIncomingCall(state.sdpOffer)
callSession?.accept(state.sdpOffer)
}
IncomingCallUI(
groupMembers = otherMembers,
@@ -155,17 +155,17 @@ fun CallScreen(
is CallState.Connected -> {
if (isInPipMode) {
PipConnectedCallUI(state = state, callController = callController, accountViewModel = accountViewModel)
PipConnectedCallUI(state = state, callSession = callSession, accountViewModel = accountViewModel)
} else {
ConnectedCallUI(
state = state,
callController = callController,
callSession = callSession,
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
onToggleMute = { callController?.toggleAudioMute() },
onToggleVideo = { callController?.toggleVideo() },
onCycleAudioRoute = { callController?.cycleAudioRoute() },
onInvitePeer = { peerPubKey -> callController?.invitePeer(peerPubKey) },
onToggleMute = { callSession?.toggleMute() },
onToggleVideo = { callSession?.toggleVideo() },
onCycleAudioRoute = { callSession?.cycleAudioRoute() },
onInvitePeer = { peerPubKey -> callSession?.invitePeer(peerPubKey) },
)
}
}
@@ -196,7 +196,7 @@ fun CallScreen(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = {
androidx.compose.material3.TextButton(
onClick = { callController?.clearError() },
onClick = { callSession?.clearError() },
) {
Text(
stringRes(R.string.call_dismiss),
@@ -71,7 +71,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.AudioRoute
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.call.session.CallSession
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -83,7 +83,7 @@ import org.webrtc.VideoTrack
@Composable
fun ConnectedCallUI(
state: CallState.Connected,
callController: CallController?,
callSession: CallSession?,
accountViewModel: AccountViewModel,
onHangup: () -> Unit,
onToggleMute: () -> Unit,
@@ -103,15 +103,15 @@ fun ConnectedCallUI(
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState()
val remoteVideoTracks by (callSession?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callSession?.activePeerVideos ?: emptySetFlow).collectAsState()
val localVideoTrack by (callSession?.localVideoTrack ?: emptyVideoFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
val isFrontCamera by (callController?.isFrontCamera ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState()
val isAudioMuted by (callSession?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callSession?.isVideoEnabled ?: defaultTrue).collectAsState()
val isFrontCamera by (callSession?.isFrontCamera ?: defaultTrue).collectAsState()
val currentAudioRoute by (callSession?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
isVideoEnabled ||
@@ -148,7 +148,7 @@ fun ConnectedCallUI(
pendingPeerPubKeys = state.pendingPeerPubKeys,
remoteVideoTracks = remoteVideoTracks,
activePeerVideos = activePeerVideos,
eglBase = callController?.getEglBase(),
eglBase = callSession?.getEglBase(),
accountViewModel = accountViewModel,
modifier = Modifier.fillMaxSize(),
)
@@ -157,7 +157,7 @@ fun ConnectedCallUI(
localVideoTrack?.let { track ->
VideoRenderer(
videoTrack = track,
eglBase = callController?.getEglBase(),
eglBase = callSession?.getEglBase(),
modifier =
Modifier
.size(120.dp, 160.dp)
@@ -220,7 +220,7 @@ fun ConnectedCallUI(
currentAudioRoute = currentAudioRoute,
onToggleMute = onToggleMute,
onToggleVideo = onToggleVideo,
onSwitchCamera = { callController?.switchCamera() },
onSwitchCamera = { callSession?.switchCamera() },
onCycleAudioRoute = onCycleAudioRoute,
onAddParticipant = { showAddParticipant = true },
onHangup = onHangup,
@@ -43,7 +43,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.ui.call.session.CallSession
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay
@@ -84,7 +84,7 @@ fun PipCallUI(
@Composable
fun PipConnectedCallUI(
state: CallState.Connected,
callController: CallController?,
callSession: CallSession?,
accountViewModel: AccountViewModel,
) {
var elapsed by remember { mutableLongStateOf(0L) }
@@ -98,10 +98,10 @@ fun PipConnectedCallUI(
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
val remoteVideoTracks by (callSession?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callSession?.activePeerVideos ?: emptySetFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultFalse).collectAsState()
val isVideoEnabled by (callSession?.isVideoEnabled ?: defaultFalse).collectAsState()
val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
isVideoEnabled ||
@@ -124,7 +124,7 @@ fun PipConnectedCallUI(
if (activeTrack != null) {
VideoRenderer(
videoTrack = activeTrack,
eglBase = callController?.getEglBase(),
eglBase = callSession?.getEglBase(),
modifier = Modifier.fillMaxSize(),
mirror = false,
)
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.call
package com.vitorpamplona.amethyst.ui.call.session
import android.content.Context
import android.content.Intent
@@ -35,7 +35,15 @@ import com.vitorpamplona.amethyst.commons.call.PeerSession
import com.vitorpamplona.amethyst.commons.call.PeerSessionManager
import com.vitorpamplona.amethyst.commons.call.SdpType
import com.vitorpamplona.amethyst.commons.call.SignalingState
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
import com.vitorpamplona.amethyst.service.call.AudioRoute
import com.vitorpamplona.amethyst.service.call.CallAudioManager
import com.vitorpamplona.amethyst.service.call.CallForegroundService
import com.vitorpamplona.amethyst.service.call.CallMediaManager
import com.vitorpamplona.amethyst.service.call.IceServerConfig
import com.vitorpamplona.amethyst.service.call.RemoteVideoMonitor
import com.vitorpamplona.amethyst.service.call.WebRtcCallSession
import com.vitorpamplona.amethyst.service.call.WebRtcPeerSessionAdapter
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
@@ -55,13 +63,18 @@ import org.webrtc.IceCandidate
import org.webrtc.VideoTrack
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
private const val TAG = "CallController"
private const val TAG = "CallSession"
private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000
/**
* One-per-Activity call session. Created in [CallActivity.onCreate],
* destroyed in [CallActivity.onDestroy] via [close]. This guarantees
* that every WebRTC/audio/notification resource is released when the
* Activity dies no guard flags, no race-sensitive state observing.
*/
@Stable
class CallController(
class CallSession(
private val context: Context,
val callManager: CallManager,
private val scope: CoroutineScope,
@@ -69,7 +82,7 @@ class CallController(
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
localPubKey: HexKey,
private val settingsProvider: () -> com.vitorpamplona.amethyst.model.AccountSettings,
) {
) : AutoCloseable {
private var peerSessionMgr = PeerSessionManager(localPubKey)
private fun webRtcSession(peerPubKey: HexKey): WebRtcCallSession? = (peerSessionMgr.getSession(peerPubKey)?.session as? WebRtcPeerSessionAdapter)?.webRtcSession
@@ -102,7 +115,6 @@ class CallController(
@Volatile private var videoPausedByProximity = false
@Volatile private var foregroundServiceStarted = false
private val cleanedUp = AtomicBoolean(false)
private val videoSenders = ConcurrentHashMap<HexKey, org.webrtc.RtpSender>()
private val pendingRenegotiation = ConcurrentHashMap<HexKey, Boolean>()
@@ -128,33 +140,27 @@ class CallController(
// ---- Initialization ----
init {
callManager.onRenegotiationOfferReceived = { event -> onRenegotiationOfferReceived(event) }
// Subscribe to renegotiation events via SharedFlow instead of a
// mutable callback, so we never miss an event across Activity
// restarts.
scope.launch {
callManager.renegotiationEvents.collect { event ->
onRenegotiationOfferReceived(event)
}
}
scope.launch {
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 {
NotificationUtils.showIncomingCallNotification(state.callerPubKey, context)
CallNotifier.showIncomingCall(state.callerPubKey, context)
}
}
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()
}
@@ -164,7 +170,7 @@ class CallController(
audioManager.stopRingbackTone()
withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context)
CallNotifier.cancelIncomingCall(context)
updateForegroundServiceNotification()
registerNetworkCallback()
}
@@ -174,19 +180,21 @@ class CallController(
audioManager.stopRingbackTone()
withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context)
CallNotifier.cancelIncomingCall(context)
updateForegroundServiceNotification()
registerNetworkCallback()
}
is CallState.Ended -> {
cleanup()
// Stop ringing/ringback — close() handles the
// full teardown when the Activity is destroyed.
audioManager.stopRinging()
audioManager.stopRingbackTone()
CallNotifier.cancelIncomingCall(context)
}
is CallState.Idle -> {
// No cleanup here — Ended already handled it.
// Ended auto-resets to Idle after 2 seconds;
// running cleanup twice is wasteful and logs errors.
// No action — Ended already stopped audio/notification.
}
}
}
@@ -216,18 +224,14 @@ class CallController(
// ---- Call initiation (caller side) ----
fun initiateGroupCall(
fun initiate(
peerPubKeys: Set<String>,
callType: CallType,
) {
Log.d(TAG) { "initiateCallInternal: peers=${peerPubKeys.map { it.take(8) }}, callType=$callType" }
Log.d(TAG) { "initiate: peers=${peerPubKeys.map { it.take(8) }}, callType=$callType" }
scope.launch {
val callId = UUID.randomUUID().toString()
_errorMessage.value = null
// A new call session begins — arm cleanup() so it will run again
// for this session's Ended transition.
cleanedUp.set(false)
applyCallSettings()
try {
withContext(Dispatchers.IO) { mediaManager.initialize(callType) }
@@ -266,17 +270,13 @@ class CallController(
// ---- Accept incoming call (callee side) ----
fun acceptIncomingCall(sdpOffer: String) {
fun accept(sdpOffer: String) {
val state = callManager.state.value
if (state !is CallState.IncomingCall) return
val callerPubKey = state.callerPubKey
scope.launch {
_errorMessage.value = null
// A new call session begins — arm cleanup() so it will run again
// for this session's Ended transition.
cleanedUp.set(false)
applyCallSettings()
try {
withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) }
@@ -505,7 +505,7 @@ class CallController(
// ---- UI toggle controls ----
fun toggleAudioMute() {
fun toggleMute() {
val muted = !_isAudioMuted.value
_isAudioMuted.value = muted
mediaManager.setAudioMuted(muted)
@@ -649,63 +649,43 @@ class CallController(
}
}
// ---- Cleanup ----
// ---- Close (AutoCloseable) ----
/**
* Releases all WebRTC, audio, and foreground-service resources for the
* current call session.
*
* 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.
* Single-shot teardown tied to Activity destruction. Unconditionally
* releases every resource this session owns: audio, ringtone, ringback,
* notification, WebRTC peer connections, network callback, foreground
* service, and proximity wake lock. No guard flags each Activity
* instance creates exactly one [CallSession] and calls [close] exactly
* once from [onDestroy].
*/
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.
override fun close() {
try {
audioManager.stopRinging()
} catch (e: Exception) {
Log.e(TAG, "cleanup: audioManager.stopRinging() failed", e)
Log.e(TAG, "close: audioManager.stopRinging() failed", e)
}
try {
audioManager.stopRingbackTone()
} catch (e: Exception) {
Log.e(TAG, "cleanup: audioManager.stopRingbackTone() failed", e)
Log.e(TAG, "close: audioManager.stopRingbackTone() failed", e)
}
try {
NotificationUtils.cancelCallNotification(context)
CallNotifier.cancelIncomingCall(context)
} catch (e: Exception) {
Log.e(TAG, "cleanup: cancelCallNotification() failed", e)
Log.e(TAG, "close: cancelIncomingCall() failed", e)
}
if (!cleanedUp.compareAndSet(false, true)) return
unregisterNetworkCallback()
try {
audioManager.release()
} catch (e: Exception) {
Log.e(TAG, "cleanup: audioManager.release() failed", e)
Log.e(TAG, "close: audioManager.release() failed", e)
}
try {
stopForegroundService()
} catch (e: Exception) {
Log.e(TAG, "cleanup: stopForegroundService() failed", e)
Log.e(TAG, "close: stopForegroundService() failed", e)
}
foregroundServiceStarted = false
@@ -714,7 +694,7 @@ class CallController(
try {
peerSessionMgr.disposeAll()
} catch (e: Exception) {
Log.e(TAG, "cleanup: sessionManager.disposeAll() failed", e)
Log.e(TAG, "close: sessionManager.disposeAll() failed", e)
}
peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey)
@@ -724,12 +704,6 @@ class CallController(
videoPausedByProximity = false
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 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 ----
@@ -41,7 +41,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
@@ -192,8 +192,7 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
LaunchedEffect(callState) {
val state = callState
if (state is CallState.IncomingCall) {
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
if (state is CallState.IncomingCall || state is CallState.Offering) {
CallActivity.launch(context)
}
}
@@ -204,11 +203,6 @@ fun BuildNavigation(
accountViewModel: AccountViewModel,
nav: Nav,
) {
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) {
accountViewModel.initCallController(context)
}
NavHost(
navController = nav.controller,
startDestination = Route.Home,
@@ -67,7 +67,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.service.call.CallController
import com.vitorpamplona.amethyst.service.cashu.CashuToken
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -205,39 +205,17 @@ class AccountViewModel(
isCallsEnabled = { account.settings.callsEnabled.value },
)
var callController: CallController? = null
private set
@Synchronized
fun initCallController(context: Context) {
if (callController != null) return
// Wire EventProcessor before creating CallController so events aren't dropped
init {
// Wire the signaling event processor eagerly so incoming call
// events are routed to CallManager even before any CallActivity
// is launched. Previously this was deferred to initCallController,
// which could miss events if the UI hadn't mounted yet.
account.newNotesPreProcessor.callManager = callManager
val controller =
CallController(
context = context,
callManager = callManager,
scope = viewModelScope,
publishWrap = { wrap -> account.publishCallSignaling(wrap) },
signerProvider = { account.signer },
localPubKey = account.signer.pubKey,
settingsProvider = { account.settings },
)
// Set callbacks before exposing controller to avoid timing races
callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.pubKey, event.sdpAnswer()) }
callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) }
callManager.onNewPeerInGroupCall = { peerPubKey -> controller.onNewPeerInGroupCall(peerPubKey) }
callManager.onMidCallOfferReceived = { peerPubKey, sdpOffer -> controller.onMidCallOfferReceived(peerPubKey, sdpOffer) }
callManager.onPeerLeft = { peerPubKey -> controller.disposePeerSession(peerPubKey) }
callController = controller
// Populate CallSessionBridge so CallActivity can launch even when the app
// is in the background (e.g. full-screen incoming call intent).
// Populate CallSessionBridge so CallActivity and background
// receivers can reach callManager + accountViewModel.
com.vitorpamplona.amethyst.service.call.CallSessionBridge
.set(callManager, controller, this)
.set(callManager, this)
}
val eventSync =
@@ -1575,7 +1553,7 @@ class AccountViewModel(
override fun onCleared() {
Log.d("AccountViewModel", "onCleared")
callController?.cleanup()
callManager.dispose()
com.vitorpamplona.amethyst.service.call.CallSessionBridge
.clear()
feedStates.destroy()
@@ -29,7 +29,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -56,15 +55,19 @@ fun ChatroomScreen(
val isCallSupported = roomId.users.size <= 5 && callsEnabled
val startVoiceCall =
rememberCallWithPermission(context) {
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VOICE)
CallActivity.launch(context)
CallActivity.launchForOutgoingCall(
context,
roomId.users.toSet(),
CallType.VOICE,
)
}
val startVideoCall =
rememberCallWithPermission(context, isVideo = true) {
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VIDEO)
CallActivity.launch(context)
CallActivity.launchForOutgoingCall(
context,
roomId.users.toSet(),
CallType.VIDEO,
)
}
DisappearingScaffold(
@@ -35,10 +35,16 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -73,7 +79,16 @@ class CallManager(
/** Called for every answer that should be applied to a PeerConnection (includes peer pubkey). */
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
var onRenegotiationOfferReceived: ((CallRenegotiateEvent) -> Unit)? = null
/**
* Renegotiation offers from remote peers are emitted as flow events so the
* Activity-scoped `CallSession` can (re)subscribe via `repeatOnLifecycle`
* without losing messages across Activity restarts. Buffered so a short
* gap between Activity teardown and re-collection cannot drop a
* renegotiation.
*/
private val _renegotiationEvents = MutableSharedFlow<CallRenegotiateEvent>(extraBufferCapacity = 8)
val renegotiationEvents: SharedFlow<CallRenegotiateEvent> = _renegotiationEvents.asSharedFlow()
/** Called when a new peer joins the group call (callee-to-callee mesh setup). */
var onNewPeerInGroupCall: ((peerPubKey: HexKey) -> Unit)? = null
@@ -88,6 +103,17 @@ class CallManager(
private var resetJob: Job? = null
private val processedEventIds = LinkedHashSet<String>()
/**
* Detached scope for the ringing watchdog so the timer survives the
* `scope` being cancelled by the owning ViewModel. The watchdog is a
* fail-safe that unconditionally forces an `Ended(TIMEOUT)` transition
* if the state stays in `IncomingCall`/`Offering` past
* `RINGING_WATCHDOG_MS`, regardless of whether any collector observes
* the state. Cancelled explicitly via [dispose].
*/
private val watchdogScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var ringingWatchdogJob: Job? = null
/** Per-peer invite timeout jobs. A separate 30-second timer is scheduled
* for each peer we are waiting on (initial group-call offerees and
* mid-call invitees) so that slow/unavailable peers can be dropped
@@ -130,6 +156,16 @@ class CallManager(
const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this
const val MAX_PROCESSED_EVENT_IDS = 2_000 // cap dedup set to prevent unbounded growth
const val MAX_COMPLETED_CALL_IDS = 200 // cap completed-call set
/**
* Hard ceiling on how long a call may remain in a ringing state
* (`IncomingCall` or `Offering`). Deliberately longer than
* [CALL_TIMEOUT_MS] (60s) so the normal timeout path gets a chance
* to fire first; this is purely the fail-safe for when that path
* never runs (e.g. a stuck coroutine, a canceled `scope`, or a
* dropped state transition).
*/
const val RINGING_WATCHDOG_MS = 65_000L
}
/** Adds [value] to a [LinkedHashSet], evicting the oldest entries when
@@ -173,6 +209,7 @@ class CallManager(
cancelAllPeerTimeouts()
peersInvitedByUs.addAll(calleePubKeys)
calleePubKeys.forEach { schedulePeerTimeout(it, callId) }
armRingingWatchdog(callId)
}
/**
@@ -236,6 +273,7 @@ class CallManager(
cancelAllPeerTimeouts()
peersInvitedByUs.add(calleePubKey)
schedulePeerTimeout(calleePubKey, callId)
armRingingWatchdog(callId)
}
publishEvent(result.wrap)
Log.d("CallManager") { "initiateCall: offer published, per-peer timeout started" }
@@ -332,6 +370,7 @@ class CallManager(
callType = callType,
sdpOffer = event.sdpOffer(),
)
armRingingWatchdog(callId)
startTimeout(callId)
}
@@ -375,6 +414,7 @@ class CallManager(
pendingPeerPubKeys = pendingOnJoin,
)
cancelTimeout()
disarmRingingWatchdog()
// Local watchdog timers only — we are not the inviter for these
// peers, so [handlePeerTimeout] will silently drop them without
// publishing any hangup (see `peersInvitedByUs`).
@@ -471,6 +511,7 @@ class CallManager(
// The answered peer no longer needs its invite timer. Peers
// still in `pending` keep theirs (scheduled in beginOffering).
cancelPeerTimeout(answeringPeer)
disarmRingingWatchdog()
Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" }
onAnswerReceived?.invoke(event)
}
@@ -637,7 +678,7 @@ class CallManager(
else -> return
}
if (callId != currentCallId) return
onRenegotiationOfferReceived?.invoke(event)
_renegotiationEvents.tryEmit(event)
}
suspend fun sendRenegotiation(
@@ -956,6 +997,7 @@ class CallManager(
_state.value = CallState.Idle
cancelTimeout()
cancelAllPeerTimeouts()
disarmRingingWatchdog()
resetJob?.cancel()
resetJob = null
processedEventIds.clear()
@@ -974,6 +1016,7 @@ class CallManager(
_state.value = CallState.Ended(callId, peerPubKeys, reason)
cancelTimeout()
cancelAllPeerTimeouts()
disarmRingingWatchdog()
resetJob?.cancel()
resetJob =
scope.launch {
@@ -985,6 +1028,52 @@ class CallManager(
}
}
// ---- Ringing watchdog ----
/**
* Arms the watchdog for [callId]. If the state is still
* `IncomingCall(callId)` or `Offering(callId)` when the watchdog
* fires, forces `Ended(TIMEOUT)` so nothing can keep ringing
* indefinitely. Calling this replaces any previously armed watchdog.
*/
private fun armRingingWatchdog(callId: String) {
ringingWatchdogJob?.cancel()
ringingWatchdogJob =
watchdogScope.launch {
delay(RINGING_WATCHDOG_MS)
stateMutex.withLock {
val cur = _state.value
val hit =
(cur is CallState.IncomingCall && cur.callId == callId) ||
(cur is CallState.Offering && cur.callId == callId)
if (hit) {
Log.d("CallManager") { "Ringing watchdog fired for $callId — forcing Ended(TIMEOUT)" }
val peers =
when (cur) {
is CallState.IncomingCall -> cur.peerPubKeys()
is CallState.Offering -> cur.peerPubKeys
else -> emptySet()
}
transitionToEnded(callId, peers, EndReason.TIMEOUT)
}
}
}
}
private fun disarmRingingWatchdog() {
ringingWatchdogJob?.cancel()
ringingWatchdogJob = null
}
/**
* Cancels all long-lived coroutines owned by this manager. Called when
* the owning account scope is torn down (logout / process shutdown).
*/
fun dispose() {
disarmRingingWatchdog()
watchdogScope.cancel()
}
// ---- Per-peer invite timeout ----
/**