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