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:
@@ -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"
|
||||
|
||||
+2
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+213
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
-7
@@ -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 =
|
||||
|
||||
-144
@@ -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,
|
||||
)
|
||||
|
||||
+55
-81
@@ -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,
|
||||
|
||||
+10
-32
@@ -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()
|
||||
|
||||
+10
-7
@@ -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(
|
||||
|
||||
+91
-2
@@ -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 ----
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user