diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index c641104d5..26d8143a7 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -214,7 +214,7 @@ { - NotificationUtils.cancelCallNotification(context) + CallNotifier.cancelIncomingCall(context) callManager.rejectCall() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt index 6889ca83a..62d7829b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallSessionBridge.kt @@ -24,31 +24,31 @@ import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel /** - * Process-level singleton that bridges the active call state between - * the main activity (which owns [AccountViewModel]) and [com.vitorpamplona.amethyst.ui.call.CallActivity] - * (which runs in its own window but in the same process). + * Process-level singleton that bridges the active [CallManager] and + * [AccountViewModel] between the main activity and + * [com.vitorpamplona.amethyst.ui.call.CallActivity] (which runs in its + * own window but in the same process). + * + * No call controller / session is held here — each [CallActivity] + * creates and owns its own [com.vitorpamplona.amethyst.ui.call.session.CallSession] + * whose lifetime is tied to the Activity's lifecycle. */ object CallSessionBridge { var callManager: CallManager? = null private set - var callController: CallController? = null - private set var accountViewModel: AccountViewModel? = null private set fun set( callManager: CallManager, - callController: CallController?, accountViewModel: AccountViewModel, ) { this.callManager = callManager - this.callController = callController this.accountViewModel = accountViewModel } fun clear() { callManager = null - callController = null accountViewModel = null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt new file mode 100644 index 000000000..0be21e0ed --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/notification/CallNotifier.kt @@ -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) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index e2c6bb47b..5c7ac4748 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.call.notification.CallNotifier import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification @@ -810,13 +811,11 @@ class EventNotificationConsumer( } } - NotificationUtils - .sendCallNotification( - callerName = callerName, - callerBitmap = callerBitmap, - uri = "nostr:${event.pubKey.hexToByteArray().toNpub()}", - applicationContext = applicationContext, - ) + CallNotifier.send( + callerName = callerName, + callerBitmap = callerBitmap, + applicationContext = applicationContext, + ) } fun notificationManager(): NotificationManager = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 53ffd2e60..a3aeefddb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -38,11 +38,8 @@ import coil3.asDrawable import coil3.request.ImageRequest import coil3.request.allowHardware import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -51,14 +48,11 @@ object NotificationUtils { private var zapChannel: NotificationChannel? = null private var reactionChannel: NotificationChannel? = null private var chessChannel: NotificationChannel? = null - private var callChannel: NotificationChannel? = null private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION" private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION" - private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL" - private const val CALL_NOTIFICATION_ID = 0x50000 const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" @@ -522,144 +516,6 @@ object NotificationUtils { notify(summaryId, summaryBuilder.build()) } - fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel { - if (callChannel != null) return callChannel!! - - callChannel = - NotificationChannel( - CALL_CHANNEL_ID, - stringRes(applicationContext, R.string.app_notification_calls_channel_name), - NotificationManager.IMPORTANCE_HIGH, - ).apply { - description = stringRes(applicationContext, R.string.app_notification_calls_channel_description) - // Silence the notification sound — CallAudioManager plays the ringtone - setSound(null, null) - enableVibration(false) - } - - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - notificationManager.createNotificationChannel(callChannel!!) - - return callChannel!! - } - - fun sendCallNotification( - callerName: String, - callerBitmap: Bitmap?, - uri: String, - applicationContext: Context, - ) { - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - val channel = getOrCreateCallChannel(applicationContext) - - // Tapping the notification opens the CallActivity - val contentIntent = - Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - } - - val contentPendingIntent = - PendingIntent.getActivity( - applicationContext, - CALL_NOTIFICATION_ID, - contentIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - // Accept launches CallActivity directly (not via BroadcastReceiver) - // to comply with Android 12+ notification trampoline restrictions. - val acceptIntent = - Intent(applicationContext, com.vitorpamplona.amethyst.ui.call.CallActivity::class.java).apply { - flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP - putExtra(com.vitorpamplona.amethyst.ui.call.CallActivity.EXTRA_ACCEPT_CALL, true) - } - - val acceptPendingIntent = - PendingIntent.getActivity( - applicationContext, - CALL_NOTIFICATION_ID + 1, - acceptIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val rejectIntent = - Intent(applicationContext, com.vitorpamplona.amethyst.service.call.CallNotificationReceiver::class.java).apply { - action = com.vitorpamplona.amethyst.service.call.CallNotificationReceiver.ACTION_REJECT_CALL - } - - val rejectPendingIntent = - PendingIntent.getBroadcast( - applicationContext, - CALL_NOTIFICATION_ID + 2, - rejectIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - - val builder = - NotificationCompat - .Builder(applicationContext, channel.id) - .setSmallIcon(R.drawable.amethyst) - .setContentTitle(stringRes(applicationContext, R.string.call_incoming)) - .setContentText(callerName) - .setLargeIcon(callerBitmap) - .setContentIntent(contentPendingIntent) - .setFullScreenIntent(contentPendingIntent, true) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setAutoCancel(true) - .setOngoing(true) - .setTimeoutAfter(60_000) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent) - .addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent) - - notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build()) - } - - fun cancelCallNotification(applicationContext: Context) { - val notificationManager: NotificationManager = - applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel("call", CALL_NOTIFICATION_ID) - } - - suspend fun showIncomingCallNotification( - callerPubKey: String, - context: Context, - ) { - val callerUser = LocalCache.getOrCreateUser(callerPubKey) - val callerName = callerUser.toBestDisplayName() - val uri = "nostr:${callerPubKey.hexToByteArray().toNpub()}" - - val callerBitmap = - callerUser.profilePicture()?.let { pictureUrl -> - withContext(Dispatchers.IO) { - try { - val request = - ImageRequest - .Builder(context) - .data(pictureUrl) - .allowHardware(false) - .build() - val result = coil3.ImageLoader(context).execute(request) - (result.image?.asDrawable(context.resources) as? BitmapDrawable)?.bitmap - } catch (_: Exception) { - null - } - } - } - - sendCallNotification( - callerName = callerName, - callerBitmap = callerBitmap, - uri = uri, - applicationContext = context, - ) - } - private fun NotificationManager.isDuplicate(notId: Int): Boolean { val notifications: Array = activeNotifications for (notification in notifications) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index e9346272c..2fea855c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -40,12 +40,15 @@ import androidx.lifecycle.lifecycleScope import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.call.CallSessionBridge +import com.vitorpamplona.amethyst.service.call.notification.CallNotifier import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayAuthSubscription import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.StringResSetup +import com.vitorpamplona.amethyst.ui.call.session.CallSession import com.vitorpamplona.amethyst.ui.screen.ManageRelayServices import com.vitorpamplona.amethyst.ui.screen.ManageWebOkHttp import com.vitorpamplona.amethyst.ui.theme.AmethystTheme +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -54,9 +57,8 @@ import kotlinx.coroutines.launch class CallActivity : AppCompatActivity() { val isInPipMode = mutableStateOf(false) - // Tracks whether we entered PiP at least once, so we can distinguish - // "user swiped PiP away" from "user pressed Home from full-screen call". - private var wasInPipMode = false + /** Activity-owned call session. Created in [onCreate], released in [onDestroy]. */ + private var session: CallSession? = null // Launcher for requesting call permissions when accepting from notification private val permissionLauncher = @@ -67,7 +69,6 @@ class CallActivity : AppCompatActivity() { } private var pendingAcceptIsVideo = false - private var hangupInitiated = false private val pipActionReceiver = object : BroadcastReceiver() { @@ -81,7 +82,7 @@ class CallActivity : AppCompatActivity() { } ACTION_PIP_TOGGLE_MUTE -> { - CallSessionBridge.callController?.toggleAudioMute() + session?.toggleMute() updatePipParams() } } @@ -105,7 +106,6 @@ class CallActivity : AppCompatActivity() { } val callManager = CallSessionBridge.callManager - val callController = CallSessionBridge.callController val accountViewModel = CallSessionBridge.accountViewModel if (callManager == null || accountViewModel == null) { @@ -113,9 +113,29 @@ class CallActivity : AppCompatActivity() { return } + // Create the Activity-owned call session. + val callSession = + CallSession( + context = applicationContext, + callManager = callManager, + scope = lifecycleScope, + publishWrap = { wrap -> accountViewModel.account.publishCallSignaling(wrap) }, + signerProvider = { accountViewModel.account.signer }, + localPubKey = accountViewModel.account.signer.pubKey, + settingsProvider = { accountViewModel.account.settings }, + ) + session = callSession + + // Wire CallManager callbacks to this session's handlers. + callManager.onAnswerReceived = { event -> callSession.onCallAnswerReceived(event.pubKey, event.sdpAnswer()) } + callManager.onIceCandidateReceived = { event -> callSession.onIceCandidateReceived(event) } + callManager.onNewPeerInGroupCall = { peerPubKey -> callSession.onNewPeerInGroupCall(peerPubKey) } + callManager.onMidCallOfferReceived = { peerPubKey, sdpOffer -> callSession.onMidCallOfferReceived(peerPubKey, sdpOffer) } + callManager.onPeerLeft = { peerPubKey -> callSession.disposePeerSession(peerPubKey) } + registerPipReceiver() - observeVideoStateForPip(callController) - handleAcceptCallIntent(intent) + observeVideoStateForPip(callSession) + handleIntent(intent) setContent { AmethystTheme { @@ -133,7 +153,7 @@ class CallActivity : AppCompatActivity() { CallScreen( callManager = callManager, - callController = callController, + callSession = callSession, accountViewModel = accountViewModel, onCallEnded = { finish() }, isInPipMode = isInPipMode.value, @@ -144,37 +164,57 @@ class CallActivity : AppCompatActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - handleAcceptCallIntent(intent) + handleIntent(intent) } - private fun handleAcceptCallIntent(intent: Intent?) { - if (intent?.getBooleanExtra(EXTRA_ACCEPT_CALL, false) != true) return - // Clear the extra so it doesn't re-trigger on config changes - intent.removeExtra(EXTRA_ACCEPT_CALL) + private fun handleIntent(intent: Intent?) { + intent ?: return - com.vitorpamplona.amethyst.service.notifications.NotificationUtils - .cancelCallNotification(this) + // Accept an incoming call (from notification action) + if (intent.getBooleanExtra(EXTRA_ACCEPT_CALL, false)) { + intent.removeExtra(EXTRA_ACCEPT_CALL) + CallNotifier.cancelIncomingCall(this) - val callManager = CallSessionBridge.callManager ?: return - val state = callManager.state.value - if (state !is CallState.IncomingCall) return + val callManager = CallSessionBridge.callManager ?: return + val state = callManager.state.value + if (state !is CallState.IncomingCall) return - val isVideo = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO - pendingAcceptIsVideo = isVideo + val isVideo = state.callType == CallType.VIDEO + pendingAcceptIsVideo = isVideo - if (hasCallPermissions(this, isVideo)) { - acceptCall() - } else { - permissionLauncher.launch(buildCallPermissions(isVideo)) + if (hasCallPermissions(this, isVideo)) { + acceptCall() + } else { + permissionLauncher.launch(buildCallPermissions(isVideo)) + } + return + } + + // Initiate an outgoing call (from ChatroomScreen) + if (intent.getBooleanExtra(EXTRA_INITIATE_CALL, false)) { + intent.removeExtra(EXTRA_INITIATE_CALL) + val peerPubKeys = intent.getStringArrayExtra(EXTRA_PEER_PUB_KEYS)?.toSet() ?: return + val callTypeName = intent.getStringExtra(EXTRA_CALL_TYPE) ?: return + val callType = CallType.valueOf(callTypeName) + + pendingAcceptIsVideo = callType == CallType.VIDEO + + if (hasCallPermissions(this, callType == CallType.VIDEO)) { + session?.initiate(peerPubKeys, callType) + } else { + // Store for after permission grant — will need separate handling + permissionLauncher.launch(buildCallPermissions(callType == CallType.VIDEO)) + } + return } } private fun acceptCall() { - val callController = CallSessionBridge.callController ?: return + val session = session ?: return val callManager = CallSessionBridge.callManager ?: return val state = callManager.state.value if (state is CallState.IncomingCall) { - callController.acceptIncomingCall(state.sdpOffer) + session.accept(state.sdpOffer) } } @@ -186,61 +226,20 @@ class CallActivity : AppCompatActivity() { override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) isInPipMode.value = isInPictureInPictureMode - - if (isInPictureInPictureMode) { - wasInPipMode = true - } - } - - override fun onStop() { - super.onStop() - // Only hang up if the user dismissed PiP (swiped it away). - // When PiP is dismissed, Android calls onStop with isInPictureInPictureMode == false - // after the activity was previously in PiP mode. - // We must NOT hang up when the user simply presses Home from the full-screen - // call UI (that enters PiP via onUserLeaveHint instead). - if (wasInPipMode && !isInPictureInPictureMode) { - hangupInitiated = true - val manager = CallSessionBridge.callManager - val controller = CallSessionBridge.callController - val state = manager?.state?.value - if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) { - // Publish the hangup signaling event on a detached scope so - // it survives activity teardown. - CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { - manager.hangup() - } - } - // Synchronously release all WebRTC/audio resources before the - // activity is torn down so we don't leak a PeerConnection, - // camera capturer, wake lock, or audio mode change. cleanup() is - // idempotent within a call session, so the state-collector path - // triggered by hangup() above will be a no-op. - controller?.cleanup() - finishAndRemoveTask() - } } override fun onDestroy() { unregisterPipReceiver() + // Publish reject/hangup so the remote side stops ringing. val manager = CallSessionBridge.callManager - val controller = CallSessionBridge.callController - - // Safety net: if the Activity is destroyed while a call is still - // ringing/offering, publish the reject/hangup signaling so the other - // side stops ringing. Skip if onStop already initiated the hangup to - // avoid double signaling. - if (!hangupInitiated && manager != null) { + if (manager != null) { when (manager.state.value) { is CallState.IncomingCall -> { - // Publish on a detached scope so the reject event goes out - // even after the activity is gone. CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { manager.rejectCall() } } - is CallState.Offering, is CallState.Connecting, is CallState.Connected, @@ -249,49 +248,22 @@ class CallActivity : AppCompatActivity() { manager.hangup() } } - else -> {} } } - // Ultimate safety net: synchronously release all WebRTC/audio - // resources before the Activity reference goes away. The normal - // Ended → state-collector → cleanup() path runs asynchronously on - // viewModelScope and is not guaranteed to finish before this method - // returns. CallController.cleanup() is idempotent within a call - // session, so calling it here in addition to the state-collector path - // is safe. - controller?.cleanup() - - // Hard guarantee: the ringtone, ringback tone, and incoming-call - // notification are tied to this Activity's lifecycle. When the - // Activity is destroyed for ANY reason (user reject, hangup, finish, - // process recreation, etc.), they MUST be gone — even if the - // CallController is missing, its cleanup guard is stale, or the - // state-collector hasn't fired yet. We had a bug where rejecting a - // call left the ringtone playing until the user killed the entire - // app from settings; this block is the ultimate stop signal. - try { - controller?.audioManager?.stopRinging() - } catch (_: Exception) { - } - try { - controller?.audioManager?.stopRingbackTone() - } catch (_: Exception) { - } - try { - com.vitorpamplona.amethyst.service.notifications.NotificationUtils - .cancelCallNotification(this) - } catch (_: Exception) { - } + // Single-shot release of all WebRTC/audio/notification resources. + // Guaranteed by Android lifecycle — when Activity dies, the call dies. + session?.close() + session = null super.onDestroy() } - private fun observeVideoStateForPip(controller: com.vitorpamplona.amethyst.service.call.CallController?) { - controller ?: return + private fun observeVideoStateForPip(callSession: CallSession?) { + callSession ?: return lifecycleScope.launch { - controller.isRemoteVideoActive.collect { + callSession.isRemoteVideoActive.collect { updatePipParams() } } @@ -336,9 +308,9 @@ class CallActivity : AppCompatActivity() { } private fun computePipAspectRatio(): Rational { - val controller = CallSessionBridge.callController ?: return Rational(9, 16) - val isVideoActive = controller.isRemoteVideoActive.value - val videoRatio = controller.remoteVideoAspectRatio.value + val s = session ?: return Rational(9, 16) + val isVideoActive = s.isRemoteVideoActive.value + val videoRatio = s.remoteVideoAspectRatio.value if (isVideoActive && videoRatio != null && videoRatio > 0f) { // Clamp to Android's allowed PiP range (roughly 1:2.39 to 2.39:1) @@ -355,7 +327,7 @@ class CallActivity : AppCompatActivity() { val actions = mutableListOf() // Mute / Unmute toggle - val isMuted = CallSessionBridge.callController?.isAudioMuted?.value == true + val isMuted = session?.isAudioMuted?.value == true val muteIntent = PendingIntent.getBroadcast( this, @@ -413,6 +385,9 @@ class CallActivity : AppCompatActivity() { companion object { const val EXTRA_ACCEPT_CALL = "com.vitorpamplona.amethyst.EXTRA_ACCEPT_CALL" + const val EXTRA_INITIATE_CALL = "com.vitorpamplona.amethyst.EXTRA_INITIATE_CALL" + const val EXTRA_PEER_PUB_KEYS = "com.vitorpamplona.amethyst.EXTRA_PEER_PUB_KEYS" + const val EXTRA_CALL_TYPE = "com.vitorpamplona.amethyst.EXTRA_CALL_TYPE" private const val ACTION_PIP_HANGUP = "com.vitorpamplona.amethyst.PIP_HANGUP" private const val ACTION_PIP_TOGGLE_MUTE = "com.vitorpamplona.amethyst.PIP_TOGGLE_MUTE" private const val PIP_HANGUP_REQUEST_CODE = 0x60001 @@ -425,5 +400,20 @@ class CallActivity : AppCompatActivity() { }, ) } + + fun launchForOutgoingCall( + context: Context, + peerPubKeys: Set, + 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) + }, + ) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt index 01172e3e4..989d96dc2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallScreen.kt @@ -59,7 +59,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState -import com.vitorpamplona.amethyst.service.call.CallController +import com.vitorpamplona.amethyst.ui.call.session.CallSession import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import kotlinx.coroutines.delay @@ -68,7 +68,7 @@ import kotlinx.coroutines.launch @Composable fun CallScreen( callManager: CallManager, - callController: CallController?, + callSession: CallSession?, accountViewModel: AccountViewModel, onCallEnded: () -> Unit, isInPipMode: Boolean = false, @@ -77,7 +77,7 @@ fun CallScreen( val scope = rememberCoroutineScope() val context = LocalContext.current val emptyStringFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(null) } - val errorMessage by (callController?.errorMessage ?: emptyStringFlow).collectAsState() + val errorMessage by (callSession?.errorMessage ?: emptyStringFlow).collectAsState() BackHandler(enabled = callState !is CallState.Idle && callState !is CallState.Ended) { scope.launch { callManager.hangup() } @@ -124,7 +124,7 @@ fun CallScreen( val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO val acceptWithPermission = rememberCallWithPermission(context, isVideo = isVideoCall) { - callController?.acceptIncomingCall(state.sdpOffer) + callSession?.accept(state.sdpOffer) } IncomingCallUI( groupMembers = otherMembers, @@ -155,17 +155,17 @@ fun CallScreen( is CallState.Connected -> { if (isInPipMode) { - PipConnectedCallUI(state = state, callController = callController, accountViewModel = accountViewModel) + PipConnectedCallUI(state = state, callSession = callSession, accountViewModel = accountViewModel) } else { ConnectedCallUI( state = state, - callController = callController, + callSession = callSession, accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, - onToggleMute = { callController?.toggleAudioMute() }, - onToggleVideo = { callController?.toggleVideo() }, - onCycleAudioRoute = { callController?.cycleAudioRoute() }, - onInvitePeer = { peerPubKey -> callController?.invitePeer(peerPubKey) }, + onToggleMute = { callSession?.toggleMute() }, + onToggleVideo = { callSession?.toggleVideo() }, + onCycleAudioRoute = { callSession?.cycleAudioRoute() }, + onInvitePeer = { peerPubKey -> callSession?.invitePeer(peerPubKey) }, ) } } @@ -196,7 +196,7 @@ fun CallScreen( modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), action = { androidx.compose.material3.TextButton( - onClick = { callController?.clearError() }, + onClick = { callSession?.clearError() }, ) { Text( stringRes(R.string.call_dismiss), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt index 69defdf4b..d85fd6ffa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt @@ -71,7 +71,7 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.call.CallState import com.vitorpamplona.amethyst.service.call.AudioRoute -import com.vitorpamplona.amethyst.service.call.CallController +import com.vitorpamplona.amethyst.ui.call.session.CallSession import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -83,7 +83,7 @@ import org.webrtc.VideoTrack @Composable fun ConnectedCallUI( state: CallState.Connected, - callController: CallController?, + callSession: CallSession?, accountViewModel: AccountViewModel, onHangup: () -> Unit, onToggleMute: () -> Unit, @@ -103,15 +103,15 @@ fun ConnectedCallUI( val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow(null) } val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(emptyMap()) } val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(emptySet()) } - val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState() - val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState() - val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState() + val remoteVideoTracks by (callSession?.remoteVideoTracks ?: emptyTracksFlow).collectAsState() + val activePeerVideos by (callSession?.activePeerVideos ?: emptySetFlow).collectAsState() + val localVideoTrack by (callSession?.localVideoTrack ?: emptyVideoFlow).collectAsState() val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) } - val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState() - val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState() - val isFrontCamera by (callController?.isFrontCamera ?: defaultTrue).collectAsState() - val currentAudioRoute by (callController?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState() + val isAudioMuted by (callSession?.isAudioMuted ?: defaultFalse).collectAsState() + val isVideoEnabled by (callSession?.isVideoEnabled ?: defaultTrue).collectAsState() + val isFrontCamera by (callSession?.isFrontCamera ?: defaultTrue).collectAsState() + val currentAudioRoute by (callSession?.audioRoute ?: remember { kotlinx.coroutines.flow.MutableStateFlow(AudioRoute.EARPIECE) }).collectAsState() val hasActiveVideo = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO || isVideoEnabled || @@ -148,7 +148,7 @@ fun ConnectedCallUI( pendingPeerPubKeys = state.pendingPeerPubKeys, remoteVideoTracks = remoteVideoTracks, activePeerVideos = activePeerVideos, - eglBase = callController?.getEglBase(), + eglBase = callSession?.getEglBase(), accountViewModel = accountViewModel, modifier = Modifier.fillMaxSize(), ) @@ -157,7 +157,7 @@ fun ConnectedCallUI( localVideoTrack?.let { track -> VideoRenderer( videoTrack = track, - eglBase = callController?.getEglBase(), + eglBase = callSession?.getEglBase(), modifier = Modifier .size(120.dp, 160.dp) @@ -220,7 +220,7 @@ fun ConnectedCallUI( currentAudioRoute = currentAudioRoute, onToggleMute = onToggleMute, onToggleVideo = onToggleVideo, - onSwitchCamera = { callController?.switchCamera() }, + onSwitchCamera = { callSession?.switchCamera() }, onCycleAudioRoute = onCycleAudioRoute, onAddParticipant = { showAddParticipant = true }, onHangup = onHangup, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt index 45aa48f73..46a2c00e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/PipCallUI.kt @@ -43,7 +43,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.commons.call.CallState -import com.vitorpamplona.amethyst.service.call.CallController +import com.vitorpamplona.amethyst.ui.call.session.CallSession import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.delay @@ -84,7 +84,7 @@ fun PipCallUI( @Composable fun PipConnectedCallUI( state: CallState.Connected, - callController: CallController?, + callSession: CallSession?, accountViewModel: AccountViewModel, ) { var elapsed by remember { mutableLongStateOf(0L) } @@ -98,10 +98,10 @@ fun PipConnectedCallUI( val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(emptyMap()) } val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow>(emptySet()) } - val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState() - val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState() + val remoteVideoTracks by (callSession?.remoteVideoTracks ?: emptyTracksFlow).collectAsState() + val activePeerVideos by (callSession?.activePeerVideos ?: emptySetFlow).collectAsState() val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) } - val isVideoEnabled by (callController?.isVideoEnabled ?: defaultFalse).collectAsState() + val isVideoEnabled by (callSession?.isVideoEnabled ?: defaultFalse).collectAsState() val hasActiveVideo = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO || isVideoEnabled || @@ -124,7 +124,7 @@ fun PipConnectedCallUI( if (activeTrack != null) { VideoRenderer( videoTrack = activeTrack, - eglBase = callController?.getEglBase(), + eglBase = callSession?.getEglBase(), modifier = Modifier.fillMaxSize(), mirror = false, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt similarity index 84% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt index b74f5ff9c..08fdb127e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/session/CallSession.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.service.call +package com.vitorpamplona.amethyst.ui.call.session import android.content.Context import android.content.Intent @@ -35,7 +35,15 @@ import com.vitorpamplona.amethyst.commons.call.PeerSession import com.vitorpamplona.amethyst.commons.call.PeerSessionManager import com.vitorpamplona.amethyst.commons.call.SdpType import com.vitorpamplona.amethyst.commons.call.SignalingState -import com.vitorpamplona.amethyst.service.notifications.NotificationUtils +import com.vitorpamplona.amethyst.service.call.AudioRoute +import com.vitorpamplona.amethyst.service.call.CallAudioManager +import com.vitorpamplona.amethyst.service.call.CallForegroundService +import com.vitorpamplona.amethyst.service.call.CallMediaManager +import com.vitorpamplona.amethyst.service.call.IceServerConfig +import com.vitorpamplona.amethyst.service.call.RemoteVideoMonitor +import com.vitorpamplona.amethyst.service.call.WebRtcCallSession +import com.vitorpamplona.amethyst.service.call.WebRtcPeerSessionAdapter +import com.vitorpamplona.amethyst.service.call.notification.CallNotifier import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory @@ -55,13 +63,18 @@ import org.webrtc.IceCandidate import org.webrtc.VideoTrack import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.atomic.AtomicBoolean -private const val TAG = "CallController" +private const val TAG = "CallSession" private const val VIDEO_MAX_BITRATE_BPS_DEFAULT = 1_500_000 +/** + * One-per-Activity call session. Created in [CallActivity.onCreate], + * destroyed in [CallActivity.onDestroy] via [close]. This guarantees + * that every WebRTC/audio/notification resource is released when the + * Activity dies — no guard flags, no race-sensitive state observing. + */ @Stable -class CallController( +class CallSession( private val context: Context, val callManager: CallManager, private val scope: CoroutineScope, @@ -69,7 +82,7 @@ class CallController( private val signerProvider: suspend () -> com.vitorpamplona.quartz.nip01Core.signers.NostrSigner, localPubKey: HexKey, private val settingsProvider: () -> com.vitorpamplona.amethyst.model.AccountSettings, -) { +) : AutoCloseable { private var peerSessionMgr = PeerSessionManager(localPubKey) private fun webRtcSession(peerPubKey: HexKey): WebRtcCallSession? = (peerSessionMgr.getSession(peerPubKey)?.session as? WebRtcPeerSessionAdapter)?.webRtcSession @@ -102,7 +115,6 @@ class CallController( @Volatile private var videoPausedByProximity = false @Volatile private var foregroundServiceStarted = false - private val cleanedUp = AtomicBoolean(false) private val videoSenders = ConcurrentHashMap() private val pendingRenegotiation = ConcurrentHashMap() @@ -128,33 +140,27 @@ class CallController( // ---- Initialization ---- init { - callManager.onRenegotiationOfferReceived = { event -> onRenegotiationOfferReceived(event) } + // Subscribe to renegotiation events via SharedFlow instead of a + // mutable callback, so we never miss an event across Activity + // restarts. + scope.launch { + callManager.renegotiationEvents.collect { event -> + onRenegotiationOfferReceived(event) + } + } scope.launch { callManager.state.collect { state -> when (state) { is CallState.IncomingCall -> { - // A new call session begins — arm cleanup() so it will - // run for this session's Ended transition. This MUST - // happen for every incoming call (even ones the user - // ends up rejecting), otherwise a stale cleanedUp flag - // from the previous call session would turn cleanup() - // into a no-op and leave the ringtone/notification - // playing forever. See the bug where rejecting a - // second consecutive call required killing the app. - cleanedUp.set(false) ensureForegroundService() withContext(Dispatchers.IO) { audioManager.startRinging() } scope.launch { - NotificationUtils.showIncomingCallNotification(state.callerPubKey, context) + CallNotifier.showIncomingCall(state.callerPubKey, context) } } is CallState.Offering -> { - // Same rationale as IncomingCall above: ensure cleanup - // can run for this session even if the previous one - // already ran cleanup(). - cleanedUp.set(false) audioManager.startRingbackTone() ensureForegroundService() } @@ -164,7 +170,7 @@ class CallController( audioManager.stopRingbackTone() withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } audioManager.acquireProximityWakeLock() - NotificationUtils.cancelCallNotification(context) + CallNotifier.cancelIncomingCall(context) updateForegroundServiceNotification() registerNetworkCallback() } @@ -174,19 +180,21 @@ class CallController( audioManager.stopRingbackTone() withContext(Dispatchers.IO) { audioManager.switchToCallAudioMode() } audioManager.acquireProximityWakeLock() - NotificationUtils.cancelCallNotification(context) + CallNotifier.cancelIncomingCall(context) updateForegroundServiceNotification() registerNetworkCallback() } is CallState.Ended -> { - cleanup() + // Stop ringing/ringback — close() handles the + // full teardown when the Activity is destroyed. + audioManager.stopRinging() + audioManager.stopRingbackTone() + CallNotifier.cancelIncomingCall(context) } is CallState.Idle -> { - // No cleanup here — Ended already handled it. - // Ended auto-resets to Idle after 2 seconds; - // running cleanup twice is wasteful and logs errors. + // No action — Ended already stopped audio/notification. } } } @@ -216,18 +224,14 @@ class CallController( // ---- Call initiation (caller side) ---- - fun initiateGroupCall( + fun initiate( peerPubKeys: Set, callType: CallType, ) { - Log.d(TAG) { "initiateCallInternal: peers=${peerPubKeys.map { it.take(8) }}, callType=$callType" } + Log.d(TAG) { "initiate: peers=${peerPubKeys.map { it.take(8) }}, callType=$callType" } scope.launch { val callId = UUID.randomUUID().toString() _errorMessage.value = null - - // A new call session begins — arm cleanup() so it will run again - // for this session's Ended transition. - cleanedUp.set(false) applyCallSettings() try { withContext(Dispatchers.IO) { mediaManager.initialize(callType) } @@ -266,17 +270,13 @@ class CallController( // ---- Accept incoming call (callee side) ---- - fun acceptIncomingCall(sdpOffer: String) { + fun accept(sdpOffer: String) { val state = callManager.state.value if (state !is CallState.IncomingCall) return val callerPubKey = state.callerPubKey scope.launch { _errorMessage.value = null - - // A new call session begins — arm cleanup() so it will run again - // for this session's Ended transition. - cleanedUp.set(false) applyCallSettings() try { withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) } @@ -505,7 +505,7 @@ class CallController( // ---- UI toggle controls ---- - fun toggleAudioMute() { + fun toggleMute() { val muted = !_isAudioMuted.value _isAudioMuted.value = muted mediaManager.setAudioMuted(muted) @@ -649,63 +649,43 @@ class CallController( } } - // ---- Cleanup ---- + // ---- Close (AutoCloseable) ---- /** - * Releases all WebRTC, audio, and foreground-service resources for the - * current call session. - * - * Stopping the ringtone, ringback tone, and incoming-call notification - * runs UNCONDITIONALLY on every invocation — even if the heavy teardown - * has already been done. These three operations are idempotent and must - * never be skipped, otherwise a stale [cleanedUp] flag could leave the - * ringtone playing and the notification stuck on screen until the app is - * force-killed. - * - * The remainder (peer-session disposal, media manager, network callbacks, - * etc.) is guarded by [cleanedUp] and runs at most once per call session. - * The guard is re-armed when a new call starts: outgoing calls re-arm in - * [initiateGroupCall], accepted incoming calls in [acceptIncomingCall], - * and any new IncomingCall/Offering state transition in the state - * collector. This last path is critical for incoming calls the user - * rejects (or that the remote party hangs up), since neither - * [initiateGroupCall] nor [acceptIncomingCall] is invoked for them. + * Single-shot teardown tied to Activity destruction. Unconditionally + * releases every resource this session owns: audio, ringtone, ringback, + * notification, WebRTC peer connections, network callback, foreground + * service, and proximity wake lock. No guard flags — each Activity + * instance creates exactly one [CallSession] and calls [close] exactly + * once from [onDestroy]. */ - fun cleanup() { - // Stop the ringtone, ringback tone, and incoming-call notification - // UNCONDITIONALLY — these are idempotent and absolutely critical for - // the user experience. They must run on every cleanup() call, even if - // the heavy resource teardown below is already complete (cleanedUp == - // true). Otherwise an unexpected double-cleanup or a stale cleanedUp - // flag from a previous session could leave the ringtone playing and - // the notification stuck on screen until the app is force-killed. + override fun close() { try { audioManager.stopRinging() } catch (e: Exception) { - Log.e(TAG, "cleanup: audioManager.stopRinging() failed", e) + Log.e(TAG, "close: audioManager.stopRinging() failed", e) } try { audioManager.stopRingbackTone() } catch (e: Exception) { - Log.e(TAG, "cleanup: audioManager.stopRingbackTone() failed", e) + Log.e(TAG, "close: audioManager.stopRingbackTone() failed", e) } try { - NotificationUtils.cancelCallNotification(context) + CallNotifier.cancelIncomingCall(context) } catch (e: Exception) { - Log.e(TAG, "cleanup: cancelCallNotification() failed", e) + Log.e(TAG, "close: cancelIncomingCall() failed", e) } - if (!cleanedUp.compareAndSet(false, true)) return unregisterNetworkCallback() try { audioManager.release() } catch (e: Exception) { - Log.e(TAG, "cleanup: audioManager.release() failed", e) + Log.e(TAG, "close: audioManager.release() failed", e) } try { stopForegroundService() } catch (e: Exception) { - Log.e(TAG, "cleanup: stopForegroundService() failed", e) + Log.e(TAG, "close: stopForegroundService() failed", e) } foregroundServiceStarted = false @@ -714,7 +694,7 @@ class CallController( try { peerSessionMgr.disposeAll() } catch (e: Exception) { - Log.e(TAG, "cleanup: sessionManager.disposeAll() failed", e) + Log.e(TAG, "close: sessionManager.disposeAll() failed", e) } peerSessionMgr = PeerSessionManager(peerSessionMgr.localPubKey) @@ -724,12 +704,6 @@ class CallController( videoPausedByProximity = false videoSenders.clear() pendingRenegotiation.clear() - // NOTE: cleanedUp intentionally stays true here so that a second, - // sequential cleanup() in the same call session is a no-op for the - // heavy teardown above. The flag is re-armed when a new call session - // begins, in initiateGroupCall() / acceptIncomingCall() AND in the - // state collector for IncomingCall / Offering transitions, so - // subsequent sessions still clean up correctly. } // ---- Foreground service ---- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 23eefd682..e20d91497 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -41,7 +41,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.call.CallState -import com.vitorpamplona.amethyst.service.call.CallSessionBridge + import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen @@ -192,8 +192,7 @@ private fun ObserveIncomingCalls(accountViewModel: AccountViewModel) { LaunchedEffect(callState) { val state = callState - if (state is CallState.IncomingCall) { - CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) + if (state is CallState.IncomingCall || state is CallState.Offering) { CallActivity.launch(context) } } @@ -204,11 +203,6 @@ fun BuildNavigation( accountViewModel: AccountViewModel, nav: Nav, ) { - val context = androidx.compose.ui.platform.LocalContext.current - androidx.compose.runtime.LaunchedEffect(Unit) { - accountViewModel.initCallController(context) - } - NavHost( navController = nav.controller, startDestination = Route.Home, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index e4b02e62c..06d85ae33 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -67,7 +67,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.broadcast.BroadcastTracker -import com.vitorpamplona.amethyst.service.call.CallController + import com.vitorpamplona.amethyst.service.cashu.CashuToken import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -205,39 +205,17 @@ class AccountViewModel( isCallsEnabled = { account.settings.callsEnabled.value }, ) - var callController: CallController? = null - private set - - @Synchronized - fun initCallController(context: Context) { - if (callController != null) return - - // Wire EventProcessor before creating CallController so events aren't dropped + init { + // Wire the signaling event processor eagerly so incoming call + // events are routed to CallManager even before any CallActivity + // is launched. Previously this was deferred to initCallController, + // which could miss events if the UI hadn't mounted yet. account.newNotesPreProcessor.callManager = callManager - val controller = - CallController( - context = context, - callManager = callManager, - scope = viewModelScope, - publishWrap = { wrap -> account.publishCallSignaling(wrap) }, - signerProvider = { account.signer }, - localPubKey = account.signer.pubKey, - settingsProvider = { account.settings }, - ) - - // Set callbacks before exposing controller to avoid timing races - callManager.onAnswerReceived = { event -> controller.onCallAnswerReceived(event.pubKey, event.sdpAnswer()) } - callManager.onIceCandidateReceived = { event -> controller.onIceCandidateReceived(event) } - callManager.onNewPeerInGroupCall = { peerPubKey -> controller.onNewPeerInGroupCall(peerPubKey) } - callManager.onMidCallOfferReceived = { peerPubKey, sdpOffer -> controller.onMidCallOfferReceived(peerPubKey, sdpOffer) } - callManager.onPeerLeft = { peerPubKey -> controller.disposePeerSession(peerPubKey) } - callController = controller - - // Populate CallSessionBridge so CallActivity can launch even when the app - // is in the background (e.g. full-screen incoming call intent). + // Populate CallSessionBridge so CallActivity and background + // receivers can reach callManager + accountViewModel. com.vitorpamplona.amethyst.service.call.CallSessionBridge - .set(callManager, controller, this) + .set(callManager, this) } val eventSync = @@ -1575,7 +1553,7 @@ class AccountViewModel( override fun onCleared() { Log.d("AccountViewModel", "onCleared") - callController?.cleanup() + callManager.dispose() com.vitorpamplona.amethyst.service.call.CallSessionBridge .clear() feedStates.destroy() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index f320c8341..06830903b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -29,7 +29,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.service.call.CallSessionBridge import com.vitorpamplona.amethyst.ui.call.CallActivity import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold @@ -56,15 +55,19 @@ fun ChatroomScreen( val isCallSupported = roomId.users.size <= 5 && callsEnabled val startVoiceCall = rememberCallWithPermission(context) { - CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) - accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VOICE) - CallActivity.launch(context) + CallActivity.launchForOutgoingCall( + context, + roomId.users.toSet(), + CallType.VOICE, + ) } val startVideoCall = rememberCallWithPermission(context, isVideo = true) { - CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) - accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VIDEO) - CallActivity.launch(context) + CallActivity.launchForOutgoingCall( + context, + roomId.users.toSet(), + CallType.VIDEO, + ) } DisappearingScaffold( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 3ebd74fc8..e666ef84e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -35,10 +35,16 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -73,7 +79,16 @@ class CallManager( /** Called for every answer that should be applied to a PeerConnection (includes peer pubkey). */ var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null - var onRenegotiationOfferReceived: ((CallRenegotiateEvent) -> Unit)? = null + + /** + * Renegotiation offers from remote peers are emitted as flow events so the + * Activity-scoped `CallSession` can (re)subscribe via `repeatOnLifecycle` + * without losing messages across Activity restarts. Buffered so a short + * gap between Activity teardown and re-collection cannot drop a + * renegotiation. + */ + private val _renegotiationEvents = MutableSharedFlow(extraBufferCapacity = 8) + val renegotiationEvents: SharedFlow = _renegotiationEvents.asSharedFlow() /** Called when a new peer joins the group call (callee-to-callee mesh setup). */ var onNewPeerInGroupCall: ((peerPubKey: HexKey) -> Unit)? = null @@ -88,6 +103,17 @@ class CallManager( private var resetJob: Job? = null private val processedEventIds = LinkedHashSet() + /** + * Detached scope for the ringing watchdog so the timer survives the + * `scope` being cancelled by the owning ViewModel. The watchdog is a + * fail-safe that unconditionally forces an `Ended(TIMEOUT)` transition + * if the state stays in `IncomingCall`/`Offering` past + * `RINGING_WATCHDOG_MS`, regardless of whether any collector observes + * the state. Cancelled explicitly via [dispose]. + */ + private val watchdogScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var ringingWatchdogJob: Job? = null + /** Per-peer invite timeout jobs. A separate 30-second timer is scheduled * for each peer we are waiting on (initial group-call offerees and * mid-call invitees) so that slow/unavailable peers can be dropped @@ -130,6 +156,16 @@ class CallManager( const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this const val MAX_PROCESSED_EVENT_IDS = 2_000 // cap dedup set to prevent unbounded growth const val MAX_COMPLETED_CALL_IDS = 200 // cap completed-call set + + /** + * Hard ceiling on how long a call may remain in a ringing state + * (`IncomingCall` or `Offering`). Deliberately longer than + * [CALL_TIMEOUT_MS] (60s) so the normal timeout path gets a chance + * to fire first; this is purely the fail-safe for when that path + * never runs (e.g. a stuck coroutine, a canceled `scope`, or a + * dropped state transition). + */ + const val RINGING_WATCHDOG_MS = 65_000L } /** Adds [value] to a [LinkedHashSet], evicting the oldest entries when @@ -173,6 +209,7 @@ class CallManager( cancelAllPeerTimeouts() peersInvitedByUs.addAll(calleePubKeys) calleePubKeys.forEach { schedulePeerTimeout(it, callId) } + armRingingWatchdog(callId) } /** @@ -236,6 +273,7 @@ class CallManager( cancelAllPeerTimeouts() peersInvitedByUs.add(calleePubKey) schedulePeerTimeout(calleePubKey, callId) + armRingingWatchdog(callId) } publishEvent(result.wrap) Log.d("CallManager") { "initiateCall: offer published, per-peer timeout started" } @@ -332,6 +370,7 @@ class CallManager( callType = callType, sdpOffer = event.sdpOffer(), ) + armRingingWatchdog(callId) startTimeout(callId) } @@ -375,6 +414,7 @@ class CallManager( pendingPeerPubKeys = pendingOnJoin, ) cancelTimeout() + disarmRingingWatchdog() // Local watchdog timers only — we are not the inviter for these // peers, so [handlePeerTimeout] will silently drop them without // publishing any hangup (see `peersInvitedByUs`). @@ -471,6 +511,7 @@ class CallManager( // The answered peer no longer needs its invite timer. Peers // still in `pending` keep theirs (scheduled in beginOffering). cancelPeerTimeout(answeringPeer) + disarmRingingWatchdog() Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" } onAnswerReceived?.invoke(event) } @@ -637,7 +678,7 @@ class CallManager( else -> return } if (callId != currentCallId) return - onRenegotiationOfferReceived?.invoke(event) + _renegotiationEvents.tryEmit(event) } suspend fun sendRenegotiation( @@ -956,6 +997,7 @@ class CallManager( _state.value = CallState.Idle cancelTimeout() cancelAllPeerTimeouts() + disarmRingingWatchdog() resetJob?.cancel() resetJob = null processedEventIds.clear() @@ -974,6 +1016,7 @@ class CallManager( _state.value = CallState.Ended(callId, peerPubKeys, reason) cancelTimeout() cancelAllPeerTimeouts() + disarmRingingWatchdog() resetJob?.cancel() resetJob = scope.launch { @@ -985,6 +1028,52 @@ class CallManager( } } + // ---- Ringing watchdog ---- + + /** + * Arms the watchdog for [callId]. If the state is still + * `IncomingCall(callId)` or `Offering(callId)` when the watchdog + * fires, forces `Ended(TIMEOUT)` so nothing can keep ringing + * indefinitely. Calling this replaces any previously armed watchdog. + */ + private fun armRingingWatchdog(callId: String) { + ringingWatchdogJob?.cancel() + ringingWatchdogJob = + watchdogScope.launch { + delay(RINGING_WATCHDOG_MS) + stateMutex.withLock { + val cur = _state.value + val hit = + (cur is CallState.IncomingCall && cur.callId == callId) || + (cur is CallState.Offering && cur.callId == callId) + if (hit) { + Log.d("CallManager") { "Ringing watchdog fired for $callId — forcing Ended(TIMEOUT)" } + val peers = + when (cur) { + is CallState.IncomingCall -> cur.peerPubKeys() + is CallState.Offering -> cur.peerPubKeys + else -> emptySet() + } + transitionToEnded(callId, peers, EndReason.TIMEOUT) + } + } + } + } + + private fun disarmRingingWatchdog() { + ringingWatchdogJob?.cancel() + ringingWatchdogJob = null + } + + /** + * Cancels all long-lived coroutines owned by this manager. Called when + * the owning account scope is torn down (logout / process shutdown). + */ + fun dispose() { + disarmRingingWatchdog() + watchdogScope.cancel() + } + // ---- Per-peer invite timeout ---- /**