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 <activity
android:name=".ui.call.CallActivity" android:name=".ui.call.CallActivity"
android:autoRemoveFromRecents="true" 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:supportsPictureInPicture="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:exported="false" android:exported="false"
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.service.call
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent 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.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@@ -48,7 +48,7 @@ class CallNotificationReceiver : BroadcastReceiver() {
try { try {
when (intent.action) { when (intent.action) {
ACTION_REJECT_CALL -> { ACTION_REJECT_CALL -> {
NotificationUtils.cancelCallNotification(context) CallNotifier.cancelIncomingCall(context)
callManager.rejectCall() callManager.rejectCall()
} }
@@ -24,31 +24,31 @@ import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/** /**
* Process-level singleton that bridges the active call state between * Process-level singleton that bridges the active [CallManager] and
* the main activity (which owns [AccountViewModel]) and [com.vitorpamplona.amethyst.ui.call.CallActivity] * [AccountViewModel] between the main activity and
* (which runs in its own window but in the same process). * [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 { object CallSessionBridge {
var callManager: CallManager? = null var callManager: CallManager? = null
private set private set
var callController: CallController? = null
private set
var accountViewModel: AccountViewModel? = null var accountViewModel: AccountViewModel? = null
private set private set
fun set( fun set(
callManager: CallManager, callManager: CallManager,
callController: CallController?,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
this.callManager = callManager this.callManager = callManager
this.callController = callController
this.accountViewModel = accountViewModel this.accountViewModel = accountViewModel
} }
fun clear() { fun clear() {
callManager = null callManager = null
callController = null
accountViewModel = 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.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note 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.sendChessNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification
@@ -810,11 +811,9 @@ class EventNotificationConsumer(
} }
} }
NotificationUtils CallNotifier.send(
.sendCallNotification(
callerName = callerName, callerName = callerName,
callerBitmap = callerBitmap, callerBitmap = callerBitmap,
uri = "nostr:${event.pubKey.hexToByteArray().toNpub()}",
applicationContext = applicationContext, applicationContext = applicationContext,
) )
} }
@@ -38,11 +38,8 @@ import coil3.asDrawable
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.allowHardware import coil3.request.allowHardware
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -51,14 +48,11 @@ object NotificationUtils {
private var zapChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null
private var reactionChannel: NotificationChannel? = null private var reactionChannel: NotificationChannel? = null
private var chessChannel: 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 DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_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 REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_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 REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
@@ -522,144 +516,6 @@ object NotificationUtils {
notify(summaryId, summaryBuilder.build()) 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 { private fun NotificationManager.isDuplicate(notId: Int): Boolean {
val notifications: Array<StatusBarNotification> = activeNotifications val notifications: Array<StatusBarNotification> = activeNotifications
for (notification in notifications) { for (notification in notifications) {
@@ -40,12 +40,15 @@ import androidx.lifecycle.lifecycleScope
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.CallSessionBridge 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.authCommand.compose.RelayAuthSubscription
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.StringResSetup 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.ManageRelayServices
import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -54,9 +57,8 @@ import kotlinx.coroutines.launch
class CallActivity : AppCompatActivity() { class CallActivity : AppCompatActivity() {
val isInPipMode = mutableStateOf(false) val isInPipMode = mutableStateOf(false)
// Tracks whether we entered PiP at least once, so we can distinguish /** Activity-owned call session. Created in [onCreate], released in [onDestroy]. */
// "user swiped PiP away" from "user pressed Home from full-screen call". private var session: CallSession? = null
private var wasInPipMode = false
// Launcher for requesting call permissions when accepting from notification // Launcher for requesting call permissions when accepting from notification
private val permissionLauncher = private val permissionLauncher =
@@ -67,7 +69,6 @@ class CallActivity : AppCompatActivity() {
} }
private var pendingAcceptIsVideo = false private var pendingAcceptIsVideo = false
private var hangupInitiated = false
private val pipActionReceiver = private val pipActionReceiver =
object : BroadcastReceiver() { object : BroadcastReceiver() {
@@ -81,7 +82,7 @@ class CallActivity : AppCompatActivity() {
} }
ACTION_PIP_TOGGLE_MUTE -> { ACTION_PIP_TOGGLE_MUTE -> {
CallSessionBridge.callController?.toggleAudioMute() session?.toggleMute()
updatePipParams() updatePipParams()
} }
} }
@@ -105,7 +106,6 @@ class CallActivity : AppCompatActivity() {
} }
val callManager = CallSessionBridge.callManager val callManager = CallSessionBridge.callManager
val callController = CallSessionBridge.callController
val accountViewModel = CallSessionBridge.accountViewModel val accountViewModel = CallSessionBridge.accountViewModel
if (callManager == null || accountViewModel == null) { if (callManager == null || accountViewModel == null) {
@@ -113,9 +113,29 @@ class CallActivity : AppCompatActivity() {
return 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() registerPipReceiver()
observeVideoStateForPip(callController) observeVideoStateForPip(callSession)
handleAcceptCallIntent(intent) handleIntent(intent)
setContent { setContent {
AmethystTheme { AmethystTheme {
@@ -133,7 +153,7 @@ class CallActivity : AppCompatActivity() {
CallScreen( CallScreen(
callManager = callManager, callManager = callManager,
callController = callController, callSession = callSession,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onCallEnded = { finish() }, onCallEnded = { finish() },
isInPipMode = isInPipMode.value, isInPipMode = isInPipMode.value,
@@ -144,22 +164,22 @@ class CallActivity : AppCompatActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
handleAcceptCallIntent(intent) handleIntent(intent)
} }
private fun handleAcceptCallIntent(intent: Intent?) { private fun handleIntent(intent: Intent?) {
if (intent?.getBooleanExtra(EXTRA_ACCEPT_CALL, false) != true) return intent ?: return
// Clear the extra so it doesn't re-trigger on config changes
intent.removeExtra(EXTRA_ACCEPT_CALL)
com.vitorpamplona.amethyst.service.notifications.NotificationUtils // Accept an incoming call (from notification action)
.cancelCallNotification(this) if (intent.getBooleanExtra(EXTRA_ACCEPT_CALL, false)) {
intent.removeExtra(EXTRA_ACCEPT_CALL)
CallNotifier.cancelIncomingCall(this)
val callManager = CallSessionBridge.callManager ?: return val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value val state = callManager.state.value
if (state !is CallState.IncomingCall) return if (state !is CallState.IncomingCall) return
val isVideo = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO val isVideo = state.callType == CallType.VIDEO
pendingAcceptIsVideo = isVideo pendingAcceptIsVideo = isVideo
if (hasCallPermissions(this, isVideo)) { if (hasCallPermissions(this, isVideo)) {
@@ -167,14 +187,34 @@ class CallActivity : AppCompatActivity() {
} else { } else {
permissionLauncher.launch(buildCallPermissions(isVideo)) 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() { private fun acceptCall() {
val callController = CallSessionBridge.callController ?: return val session = session ?: return
val callManager = CallSessionBridge.callManager ?: return val callManager = CallSessionBridge.callManager ?: return
val state = callManager.state.value val state = callManager.state.value
if (state is CallState.IncomingCall) { 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) { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode) super.onPictureInPictureModeChanged(isInPictureInPictureMode)
isInPipMode.value = 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() { override fun onDestroy() {
unregisterPipReceiver() unregisterPipReceiver()
// Publish reject/hangup so the remote side stops ringing.
val manager = CallSessionBridge.callManager val manager = CallSessionBridge.callManager
val controller = CallSessionBridge.callController if (manager != null) {
// 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) {
when (manager.state.value) { when (manager.state.value) {
is CallState.IncomingCall -> { 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 { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch {
manager.rejectCall() manager.rejectCall()
} }
} }
is CallState.Offering, is CallState.Offering,
is CallState.Connecting, is CallState.Connecting,
is CallState.Connected, is CallState.Connected,
@@ -249,49 +248,22 @@ class CallActivity : AppCompatActivity() {
manager.hangup() manager.hangup()
} }
} }
else -> {} else -> {}
} }
} }
// Ultimate safety net: synchronously release all WebRTC/audio // Single-shot release of all WebRTC/audio/notification resources.
// resources before the Activity reference goes away. The normal // Guaranteed by Android lifecycle — when Activity dies, the call dies.
// Ended → state-collector → cleanup() path runs asynchronously on session?.close()
// viewModelScope and is not guaranteed to finish before this method session = null
// 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) {
}
super.onDestroy() super.onDestroy()
} }
private fun observeVideoStateForPip(controller: com.vitorpamplona.amethyst.service.call.CallController?) { private fun observeVideoStateForPip(callSession: CallSession?) {
controller ?: return callSession ?: return
lifecycleScope.launch { lifecycleScope.launch {
controller.isRemoteVideoActive.collect { callSession.isRemoteVideoActive.collect {
updatePipParams() updatePipParams()
} }
} }
@@ -336,9 +308,9 @@ class CallActivity : AppCompatActivity() {
} }
private fun computePipAspectRatio(): Rational { private fun computePipAspectRatio(): Rational {
val controller = CallSessionBridge.callController ?: return Rational(9, 16) val s = session ?: return Rational(9, 16)
val isVideoActive = controller.isRemoteVideoActive.value val isVideoActive = s.isRemoteVideoActive.value
val videoRatio = controller.remoteVideoAspectRatio.value val videoRatio = s.remoteVideoAspectRatio.value
if (isVideoActive && videoRatio != null && videoRatio > 0f) { if (isVideoActive && videoRatio != null && videoRatio > 0f) {
// Clamp to Android's allowed PiP range (roughly 1:2.39 to 2.39:1) // 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>() val actions = mutableListOf<RemoteAction>()
// Mute / Unmute toggle // Mute / Unmute toggle
val isMuted = CallSessionBridge.callController?.isAudioMuted?.value == true val isMuted = session?.isAudioMuted?.value == true
val muteIntent = val muteIntent =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
this, this,
@@ -413,6 +385,9 @@ class CallActivity : AppCompatActivity() {
companion object { companion object {
const val EXTRA_ACCEPT_CALL = "com.vitorpamplona.amethyst.EXTRA_ACCEPT_CALL" 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_HANGUP = "com.vitorpamplona.amethyst.PIP_HANGUP"
private const val ACTION_PIP_TOGGLE_MUTE = "com.vitorpamplona.amethyst.PIP_TOGGLE_MUTE" private const val ACTION_PIP_TOGGLE_MUTE = "com.vitorpamplona.amethyst.PIP_TOGGLE_MUTE"
private const val PIP_HANGUP_REQUEST_CODE = 0x60001 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.R
import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallManager
import com.vitorpamplona.amethyst.commons.call.CallState 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -68,7 +68,7 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun CallScreen( fun CallScreen(
callManager: CallManager, callManager: CallManager,
callController: CallController?, callSession: CallSession?,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onCallEnded: () -> Unit, onCallEnded: () -> Unit,
isInPipMode: Boolean = false, isInPipMode: Boolean = false,
@@ -77,7 +77,7 @@ fun CallScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val emptyStringFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<String?>(null) } 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) { BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) {
scope.launch { callManager.hangup() } scope.launch { callManager.hangup() }
@@ -124,7 +124,7 @@ fun CallScreen(
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
val acceptWithPermission = val acceptWithPermission =
rememberCallWithPermission(context, isVideo = isVideoCall) { rememberCallWithPermission(context, isVideo = isVideoCall) {
callController?.acceptIncomingCall(state.sdpOffer) callSession?.accept(state.sdpOffer)
} }
IncomingCallUI( IncomingCallUI(
groupMembers = otherMembers, groupMembers = otherMembers,
@@ -155,17 +155,17 @@ fun CallScreen(
is CallState.Connected -> { is CallState.Connected -> {
if (isInPipMode) { if (isInPipMode) {
PipConnectedCallUI(state = state, callController = callController, accountViewModel = accountViewModel) PipConnectedCallUI(state = state, callSession = callSession, accountViewModel = accountViewModel)
} else { } else {
ConnectedCallUI( ConnectedCallUI(
state = state, state = state,
callController = callController, callSession = callSession,
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
onToggleMute = { callController?.toggleAudioMute() }, onToggleMute = { callSession?.toggleMute() },
onToggleVideo = { callController?.toggleVideo() }, onToggleVideo = { callSession?.toggleVideo() },
onCycleAudioRoute = { callController?.cycleAudioRoute() }, onCycleAudioRoute = { callSession?.cycleAudioRoute() },
onInvitePeer = { peerPubKey -> callController?.invitePeer(peerPubKey) }, onInvitePeer = { peerPubKey -> callSession?.invitePeer(peerPubKey) },
) )
} }
} }
@@ -196,7 +196,7 @@ fun CallScreen(
modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp),
action = { action = {
androidx.compose.material3.TextButton( androidx.compose.material3.TextButton(
onClick = { callController?.clearError() }, onClick = { callSession?.clearError() },
) { ) {
Text( Text(
stringRes(R.string.call_dismiss), stringRes(R.string.call_dismiss),
@@ -71,7 +71,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.commons.call.CallState
import com.vitorpamplona.amethyst.service.call.AudioRoute 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.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -83,7 +83,7 @@ import org.webrtc.VideoTrack
@Composable @Composable
fun ConnectedCallUI( fun ConnectedCallUI(
state: CallState.Connected, state: CallState.Connected,
callController: CallController?, callSession: CallSession?,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
onHangup: () -> Unit, onHangup: () -> Unit,
onToggleMute: () -> Unit, onToggleMute: () -> Unit,
@@ -103,15 +103,15 @@ fun ConnectedCallUI(
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) } val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) } val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) } val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState() val remoteVideoTracks by (callSession?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState() val activePeerVideos by (callSession?.activePeerVideos ?: emptySetFlow).collectAsState()
val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState() val localVideoTrack by (callSession?.localVideoTrack ?: emptyVideoFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) } val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState() val isAudioMuted by (callSession?.isAudioMuted ?: defaultFalse).collectAsState()
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() val isVideoEnabled by (callSession?.isVideoEnabled ?: defaultTrue).collectAsState()
val isFrontCamera by (callController?.isFrontCamera ?: defaultTrue).collectAsState() val isFrontCamera by (callSession?.isFrontCamera ?: defaultTrue).collectAsState()
val currentAudioRoute by (callController?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState() val currentAudioRoute by (callSession?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState()
val hasActiveVideo = val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO || state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
isVideoEnabled || isVideoEnabled ||
@@ -148,7 +148,7 @@ fun ConnectedCallUI(
pendingPeerPubKeys = state.pendingPeerPubKeys, pendingPeerPubKeys = state.pendingPeerPubKeys,
remoteVideoTracks = remoteVideoTracks, remoteVideoTracks = remoteVideoTracks,
activePeerVideos = activePeerVideos, activePeerVideos = activePeerVideos,
eglBase = callController?.getEglBase(), eglBase = callSession?.getEglBase(),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) )
@@ -157,7 +157,7 @@ fun ConnectedCallUI(
localVideoTrack?.let { track -> localVideoTrack?.let { track ->
VideoRenderer( VideoRenderer(
videoTrack = track, videoTrack = track,
eglBase = callController?.getEglBase(), eglBase = callSession?.getEglBase(),
modifier = modifier =
Modifier Modifier
.size(120.dp, 160.dp) .size(120.dp, 160.dp)
@@ -220,7 +220,7 @@ fun ConnectedCallUI(
currentAudioRoute = currentAudioRoute, currentAudioRoute = currentAudioRoute,
onToggleMute = onToggleMute, onToggleMute = onToggleMute,
onToggleVideo = onToggleVideo, onToggleVideo = onToggleVideo,
onSwitchCamera = { callController?.switchCamera() }, onSwitchCamera = { callSession?.switchCamera() },
onCycleAudioRoute = onCycleAudioRoute, onCycleAudioRoute = onCycleAudioRoute,
onAddParticipant = { showAddParticipant = true }, onAddParticipant = { showAddParticipant = true },
onHangup = onHangup, onHangup = onHangup,
@@ -43,7 +43,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.commons.call.CallState 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.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -84,7 +84,7 @@ fun PipCallUI(
@Composable @Composable
fun PipConnectedCallUI( fun PipConnectedCallUI(
state: CallState.Connected, state: CallState.Connected,
callController: CallController?, callSession: CallSession?,
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
) { ) {
var elapsed by remember { mutableLongStateOf(0L) } var elapsed by remember { mutableLongStateOf(0L) }
@@ -98,10 +98,10 @@ fun PipConnectedCallUI(
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) } val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) } val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState() val remoteVideoTracks by (callSession?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState() val activePeerVideos by (callSession?.activePeerVideos ?: emptySetFlow).collectAsState()
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultFalse).collectAsState() val isVideoEnabled by (callSession?.isVideoEnabled ?: defaultFalse).collectAsState()
val hasActiveVideo = val hasActiveVideo =
state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO || state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO ||
isVideoEnabled || isVideoEnabled ||
@@ -124,7 +124,7 @@ fun PipConnectedCallUI(
if (activeTrack != null) { if (activeTrack != null) {
VideoRenderer( VideoRenderer(
videoTrack = activeTrack, videoTrack = activeTrack,
eglBase = callController?.getEglBase(), eglBase = callSession?.getEglBase(),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
mirror = false, mirror = false,
) )
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * 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.Context
import android.content.Intent 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.PeerSessionManager
import com.vitorpamplona.amethyst.commons.call.SdpType import com.vitorpamplona.amethyst.commons.call.SdpType
import com.vitorpamplona.amethyst.commons.call.SignalingState 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.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
@@ -55,13 +63,18 @@ import org.webrtc.IceCandidate
import org.webrtc.VideoTrack import org.webrtc.VideoTrack
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap 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 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 @Stable
class CallController( class CallSession(
private val context: Context, private val context: Context,
val callManager: CallManager, val callManager: CallManager,
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -69,7 +82,7 @@ class CallController(
private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner,
localPubKey: HexKey, localPubKey: HexKey,
private val settingsProvider: () -> com.vitorpamplona.amethyst.model.AccountSettings, private val settingsProvider: () -> com.vitorpamplona.amethyst.model.AccountSettings,
) { ) : AutoCloseable {
private var peerSessionMgr = PeerSessionManager(localPubKey) private var peerSessionMgr = PeerSessionManager(localPubKey)
private fun webRtcSession(peerPubKey: HexKey): WebRtcCallSession? = (peerSessionMgr.getSession(peerPubKey)?.session as? WebRtcPeerSessionAdapter)?.webRtcSession 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 videoPausedByProximity = false
@Volatile private var foregroundServiceStarted = false @Volatile private var foregroundServiceStarted = false
private val cleanedUp = AtomicBoolean(false)
private val videoSenders = ConcurrentHashMap<HexKey, org.webrtc.RtpSender>() private val videoSenders = ConcurrentHashMap<HexKey, org.webrtc.RtpSender>()
private val pendingRenegotiation = ConcurrentHashMap<HexKey, Boolean>() private val pendingRenegotiation = ConcurrentHashMap<HexKey, Boolean>()
@@ -128,33 +140,27 @@ class CallController(
// ---- Initialization ---- // ---- Initialization ----
init { 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 { scope.launch {
callManager.state.collect { state -> callManager.state.collect { state ->
when (state) { when (state) {
is CallState.IncomingCall -> { 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() ensureForegroundService()
withContext(Dispatchers.IO) { audioManager.startRinging() } withContext(Dispatchers.IO) { audioManager.startRinging() }
scope.launch { scope.launch {
NotificationUtils.showIncomingCallNotification(state.callerPubKey, context) CallNotifier.showIncomingCall(state.callerPubKey, context)
} }
} }
is CallState.Offering -> { 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() audioManager.startRingbackTone()
ensureForegroundService() ensureForegroundService()
} }
@@ -164,7 +170,7 @@ class CallController(
audioManager.stopRingbackTone() audioManager.stopRingbackTone()
withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
audioManager.acquireProximityWakeLock() audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context) CallNotifier.cancelIncomingCall(context)
updateForegroundServiceNotification() updateForegroundServiceNotification()
registerNetworkCallback() registerNetworkCallback()
} }
@@ -174,19 +180,21 @@ class CallController(
audioManager.stopRingbackTone() audioManager.stopRingbackTone()
withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() }
audioManager.acquireProximityWakeLock() audioManager.acquireProximityWakeLock()
NotificationUtils.cancelCallNotification(context) CallNotifier.cancelIncomingCall(context)
updateForegroundServiceNotification() updateForegroundServiceNotification()
registerNetworkCallback() registerNetworkCallback()
} }
is CallState.Ended -> { 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 -> { is CallState.Idle -> {
// No cleanup here — Ended already handled it. // No action — Ended already stopped audio/notification.
// Ended auto-resets to Idle after 2 seconds;
// running cleanup twice is wasteful and logs errors.
} }
} }
} }
@@ -216,18 +224,14 @@ class CallController(
// ---- Call initiation (caller side) ---- // ---- Call initiation (caller side) ----
fun initiateGroupCall( fun initiate(
peerPubKeys: Set<String>, peerPubKeys: Set<String>,
callType: CallType, 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 { scope.launch {
val callId = UUID.randomUUID().toString() val callId = UUID.randomUUID().toString()
_errorMessage.value = null _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() applyCallSettings()
try { try {
withContext(Dispatchers.IO) { mediaManager.initialize(callType) } withContext(Dispatchers.IO) { mediaManager.initialize(callType) }
@@ -266,17 +270,13 @@ class CallController(
// ---- Accept incoming call (callee side) ---- // ---- Accept incoming call (callee side) ----
fun acceptIncomingCall(sdpOffer: String) { fun accept(sdpOffer: String) {
val state = callManager.state.value val state = callManager.state.value
if (state !is CallState.IncomingCall) return if (state !is CallState.IncomingCall) return
val callerPubKey = state.callerPubKey val callerPubKey = state.callerPubKey
scope.launch { scope.launch {
_errorMessage.value = null _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() applyCallSettings()
try { try {
withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) } withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) }
@@ -505,7 +505,7 @@ class CallController(
// ---- UI toggle controls ---- // ---- UI toggle controls ----
fun toggleAudioMute() { fun toggleMute() {
val muted = !_isAudioMuted.value val muted = !_isAudioMuted.value
_isAudioMuted.value = muted _isAudioMuted.value = muted
mediaManager.setAudioMuted(muted) mediaManager.setAudioMuted(muted)
@@ -649,63 +649,43 @@ class CallController(
} }
} }
// ---- Cleanup ---- // ---- Close (AutoCloseable) ----
/** /**
* Releases all WebRTC, audio, and foreground-service resources for the * Single-shot teardown tied to Activity destruction. Unconditionally
* current call session. * releases every resource this session owns: audio, ringtone, ringback,
* * notification, WebRTC peer connections, network callback, foreground
* Stopping the ringtone, ringback tone, and incoming-call notification * service, and proximity wake lock. No guard flags each Activity
* runs UNCONDITIONALLY on every invocation even if the heavy teardown * instance creates exactly one [CallSession] and calls [close] exactly
* has already been done. These three operations are idempotent and must * once from [onDestroy].
* 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() { override fun close() {
// 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 { try {
audioManager.stopRinging() audioManager.stopRinging()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "cleanup: audioManager.stopRinging() failed", e) Log.e(TAG, "close: audioManager.stopRinging() failed", e)
} }
try { try {
audioManager.stopRingbackTone() audioManager.stopRingbackTone()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "cleanup: audioManager.stopRingbackTone() failed", e) Log.e(TAG, "close: audioManager.stopRingbackTone() failed", e)
} }
try { try {
NotificationUtils.cancelCallNotification(context) CallNotifier.cancelIncomingCall(context)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "cleanup: cancelCallNotification() failed", e) Log.e(TAG, "close: cancelIncomingCall() failed", e)
} }
if (!cleanedUp.compareAndSet(false, true)) return
unregisterNetworkCallback() unregisterNetworkCallback()
try { try {
audioManager.release() audioManager.release()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "cleanup: audioManager.release() failed", e) Log.e(TAG, "close: audioManager.release() failed", e)
} }
try { try {
stopForegroundService() stopForegroundService()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "cleanup: stopForegroundService() failed", e) Log.e(TAG, "close: stopForegroundService() failed", e)
} }
foregroundServiceStarted = false foregroundServiceStarted = false
@@ -714,7 +694,7 @@ class CallController(
try { try {
peerSessionMgr.disposeAll() peerSessionMgr.disposeAll()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "cleanup: sessionManager.disposeAll() failed", e) Log.e(TAG, "close: sessionManager.disposeAll() failed", e)
} }
peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey) peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey)
@@ -724,12 +704,6 @@ class CallController(
videoPausedByProximity = false videoPausedByProximity = false
videoSenders.clear() videoSenders.clear()
pendingRenegotiation.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 ---- // ---- Foreground service ----
@@ -41,7 +41,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.call.CallState 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.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
@@ -192,8 +192,7 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) {
LaunchedEffect(callState) { LaunchedEffect(callState) {
val state = callState val state = callState
if (state is CallState.IncomingCall) { if (state is CallState.IncomingCall || state is CallState.Offering) {
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
CallActivity.launch(context) CallActivity.launch(context)
} }
} }
@@ -204,11 +203,6 @@ fun BuildNavigation(
accountViewModel: AccountViewModel, accountViewModel: AccountViewModel,
nav: Nav, nav: Nav,
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(Unit) {
accountViewModel.initCallController(context)
}
NavHost( NavHost(
navController = nav.controller, navController = nav.controller,
startDestination = Route.Home, 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.OnlineChecker
import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker 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.CashuToken
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.service.checkNotInMainThread
@@ -205,39 +205,17 @@ class AccountViewModel(
isCallsEnabled = { account.settings.callsEnabled.value }, isCallsEnabled = { account.settings.callsEnabled.value },
) )
var callController: CallController? = null init {
private set // Wire the signaling event processor eagerly so incoming call
// events are routed to CallManager even before any CallActivity
@Synchronized // is launched. Previously this was deferred to initCallController,
fun initCallController(context: Context) { // which could miss events if the UI hadn't mounted yet.
if (callController != null) return
// Wire EventProcessor before creating CallController so events aren't dropped
account.newNotesPreProcessor.callManager = callManager account.newNotesPreProcessor.callManager = callManager
val controller = // Populate CallSessionBridge so CallActivity and background
CallController( // receivers can reach callManager + accountViewModel.
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).
com.vitorpamplona.amethyst.service.call.CallSessionBridge com.vitorpamplona.amethyst.service.call.CallSessionBridge
.set(callManager, controller, this) .set(callManager, this)
} }
val eventSync = val eventSync =
@@ -1575,7 +1553,7 @@ class AccountViewModel(
override fun onCleared() { override fun onCleared() {
Log.d("AccountViewModel", "onCleared") Log.d("AccountViewModel", "onCleared")
callController?.cleanup() callManager.dispose()
com.vitorpamplona.amethyst.service.call.CallSessionBridge com.vitorpamplona.amethyst.service.call.CallSessionBridge
.clear() .clear()
feedStates.destroy() feedStates.destroy()
@@ -29,7 +29,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.CallActivity
import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
@@ -56,15 +55,19 @@ fun ChatroomScreen(
val isCallSupported = roomId.users.size <= 5 && callsEnabled val isCallSupported = roomId.users.size <= 5 && callsEnabled
val startVoiceCall = val startVoiceCall =
rememberCallWithPermission(context) { rememberCallWithPermission(context) {
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) CallActivity.launchForOutgoingCall(
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VOICE) context,
CallActivity.launch(context) roomId.users.toSet(),
CallType.VOICE,
)
} }
val startVideoCall = val startVideoCall =
rememberCallWithPermission(context, isVideo = true) { rememberCallWithPermission(context, isVideo = true) {
CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) CallActivity.launchForOutgoingCall(
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VIDEO) context,
CallActivity.launch(context) roomId.users.toSet(),
CallType.VIDEO,
)
} }
DisappearingScaffold( DisappearingScaffold(
@@ -35,10 +35,16 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex 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). */ /** Called for every answer that should be applied to a PeerConnection (includes peer pubkey). */
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
var onIceCandidateReceived: ((CallIceCandidateEvent) -> 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). */ /** Called when a new peer joins the group call (callee-to-callee mesh setup). */
var onNewPeerInGroupCall: ((peerPubKey: HexKey) -> Unit)? = null var onNewPeerInGroupCall: ((peerPubKey: HexKey) -> Unit)? = null
@@ -88,6 +103,17 @@ class CallManager(
private var resetJob: Job? = null private var resetJob: Job? = null
private val processedEventIds = LinkedHashSet<String>() 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 /** Per-peer invite timeout jobs. A separate 30-second timer is scheduled
* for each peer we are waiting on (initial group-call offerees and * for each peer we are waiting on (initial group-call offerees and
* mid-call invitees) so that slow/unavailable peers can be dropped * 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_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_PROCESSED_EVENT_IDS = 2_000 // cap dedup set to prevent unbounded growth
const val MAX_COMPLETED_CALL_IDS = 200 // cap completed-call set 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 /** Adds [value] to a [LinkedHashSet], evicting the oldest entries when
@@ -173,6 +209,7 @@ class CallManager(
cancelAllPeerTimeouts() cancelAllPeerTimeouts()
peersInvitedByUs.addAll(calleePubKeys) peersInvitedByUs.addAll(calleePubKeys)
calleePubKeys.forEach { schedulePeerTimeout(it, callId) } calleePubKeys.forEach { schedulePeerTimeout(it, callId) }
armRingingWatchdog(callId)
} }
/** /**
@@ -236,6 +273,7 @@ class CallManager(
cancelAllPeerTimeouts() cancelAllPeerTimeouts()
peersInvitedByUs.add(calleePubKey) peersInvitedByUs.add(calleePubKey)
schedulePeerTimeout(calleePubKey, callId) schedulePeerTimeout(calleePubKey, callId)
armRingingWatchdog(callId)
} }
publishEvent(result.wrap) publishEvent(result.wrap)
Log.d("CallManager") { "initiateCall: offer published, per-peer timeout started" } Log.d("CallManager") { "initiateCall: offer published, per-peer timeout started" }
@@ -332,6 +370,7 @@ class CallManager(
callType = callType, callType = callType,
sdpOffer = event.sdpOffer(), sdpOffer = event.sdpOffer(),
) )
armRingingWatchdog(callId)
startTimeout(callId) startTimeout(callId)
} }
@@ -375,6 +414,7 @@ class CallManager(
pendingPeerPubKeys = pendingOnJoin, pendingPeerPubKeys = pendingOnJoin,
) )
cancelTimeout() cancelTimeout()
disarmRingingWatchdog()
// Local watchdog timers only — we are not the inviter for these // Local watchdog timers only — we are not the inviter for these
// peers, so [handlePeerTimeout] will silently drop them without // peers, so [handlePeerTimeout] will silently drop them without
// publishing any hangup (see `peersInvitedByUs`). // publishing any hangup (see `peersInvitedByUs`).
@@ -471,6 +511,7 @@ class CallManager(
// The answered peer no longer needs its invite timer. Peers // The answered peer no longer needs its invite timer. Peers
// still in `pending` keep theirs (scheduled in beginOffering). // still in `pending` keep theirs (scheduled in beginOffering).
cancelPeerTimeout(answeringPeer) cancelPeerTimeout(answeringPeer)
disarmRingingWatchdog()
Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" } Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" }
onAnswerReceived?.invoke(event) onAnswerReceived?.invoke(event)
} }
@@ -637,7 +678,7 @@ class CallManager(
else -> return else -> return
} }
if (callId != currentCallId) return if (callId != currentCallId) return
onRenegotiationOfferReceived?.invoke(event) _renegotiationEvents.tryEmit(event)
} }
suspend fun sendRenegotiation( suspend fun sendRenegotiation(
@@ -956,6 +997,7 @@ class CallManager(
_state.value = CallState.Idle _state.value = CallState.Idle
cancelTimeout() cancelTimeout()
cancelAllPeerTimeouts() cancelAllPeerTimeouts()
disarmRingingWatchdog()
resetJob?.cancel() resetJob?.cancel()
resetJob = null resetJob = null
processedEventIds.clear() processedEventIds.clear()
@@ -974,6 +1016,7 @@ class CallManager(
_state.value = CallState.Ended(callId, peerPubKeys, reason) _state.value = CallState.Ended(callId, peerPubKeys, reason)
cancelTimeout() cancelTimeout()
cancelAllPeerTimeouts() cancelAllPeerTimeouts()
disarmRingingWatchdog()
resetJob?.cancel() resetJob?.cancel()
resetJob = resetJob =
scope.launch { 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 ---- // ---- Per-peer invite timeout ----
/** /**