From 8114739166514a839f7d8b74c6720cddfd1a2d7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Apr 2026 00:35:24 +0000 Subject: [PATCH] feat: add runtime permissions, audio routing, and call notifications Runtime permissions: - RECORD_AUDIO permission prompted before initiating or accepting calls - rememberCallWithPermission() composable wraps call actions with Android runtime permission flow via accompanist Audio routing: - CallController.setAudioMuted/setVideoEnabled/setSpeakerOn now control WebRtcCallSession and Android AudioManager - Mute/speaker/video toggles in ConnectedCallUI wired to actual hardware controls Incoming call notifications: - EventNotificationConsumer handles CallOfferEvent with follow-gate and 30s staleness check - New CALL_CHANNEL notification channel (IMPORTANCE_HIGH) - NotificationUtils.sendCallNotification shows caller name with auto-dismiss after 60 seconds - Call notification cancelled on cleanup (accept/reject/hangup) https://claude.ai/code/session_017hZm7yu7CzmcQgZGSaqSXS --- .../amethyst/service/call/CallController.kt | 16 +++++ .../EventNotificationConsumer.kt | 25 +++++++ .../notifications/NotificationUtils.kt | 68 +++++++++++++++++++ .../amethyst/ui/call/CallPermissions.kt | 63 +++++++++++++++++ .../amethyst/ui/call/CallScreen.kt | 22 ++++-- .../chats/privateDM/ChatroomScreen.kt | 25 +++---- 6 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index 9280010ef..9726c458a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -22,8 +22,10 @@ package com.vitorpamplona.amethyst.service.call import android.content.Context import android.content.Intent +import android.media.AudioManager import com.vitorpamplona.amethyst.commons.call.CallManager import com.vitorpamplona.amethyst.commons.call.CallState +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory @@ -140,6 +142,19 @@ class CallController( candidates.forEach { session.addIceCandidate(it) } } + fun setAudioMuted(muted: Boolean) { + webRtcSession?.setAudioEnabled(!muted) + } + + fun setVideoEnabled(enabled: Boolean) { + webRtcSession?.setVideoEnabled(enabled) + } + + fun setSpeakerOn(on: Boolean) { + val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + audioManager.isSpeakerphoneOn = on + } + fun hangup() { scope.launch { callManager.hangup() } cleanup() @@ -147,6 +162,7 @@ class CallController( fun cleanup() { stopForegroundService() + NotificationUtils.cancelCallNotification(context) webRtcSession?.dispose() webRtcSession = null currentCallId = null 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 567b4ac44..7ae2310ae 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 @@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent +import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallOfferEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import java.math.BigDecimal @@ -138,6 +139,10 @@ class EventNotificationConsumer( is LiveChessMoveEvent -> { notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn) } + + is CallOfferEvent -> { + notifyIncomingCall(innerEvent, account) + } } } } @@ -620,6 +625,26 @@ class EventNotificationConsumer( } } + private fun notifyIncomingCall( + event: CallOfferEvent, + account: Account, + ) { + if (!account.isFollowing(event.pubKey)) return + + if (TimeUtils.now() - event.createdAt > 30) return + + val callerUser = LocalCache.getUserIfExists(event.pubKey) + val callerName = callerUser?.toBestDisplayName() ?: event.pubKey.take(8) + "..." + + NotificationUtils + .sendCallNotification( + callerName = callerName, + callerBitmap = null, + uri = "nostr:${event.pubKey.hexToByteArray().toNpub()}", + applicationContext = applicationContext, + ) + } + fun notificationManager(): NotificationManager = ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as 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 ed5e76ac9..2dd661bf6 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 @@ -47,11 +47,14 @@ 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" @@ -510,6 +513,71 @@ object NotificationUtils { notify(summaryId, summaryBuilder.build()) } + fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel { + if (callChannel != null) return callChannel!! + + callChannel = + NotificationChannel( + CALL_CHANNEL_ID, + "Incoming calls", + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = "Notifications for incoming voice and video calls" + } + + 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) + + val contentIntent = + Intent(applicationContext, MainActivity::class.java).apply { data = uri.toUri() } + + val contentPendingIntent = + PendingIntent.getActivity( + applicationContext, + CALL_NOTIFICATION_ID, + contentIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val builder = + NotificationCompat + .Builder(applicationContext, channel.id) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle("Incoming call") + .setContentText(callerName) + .setLargeIcon(callerBitmap) + .setContentIntent(contentPendingIntent) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setAutoCancel(true) + .setOngoing(true) + .setTimeoutAfter(60_000) + + 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) + } + 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/CallPermissions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt new file mode 100644 index 000000000..41f2acbea --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallPermissions.kt @@ -0,0 +1,63 @@ +/* + * 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.ui.call + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.core.content.ContextCompat + +@Composable +fun rememberCallPermissionLauncher(onGranted: () -> Unit) = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) onGranted() + } + +fun hasAudioPermission(context: Context) = ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + +@Composable +fun rememberCallWithPermission( + context: android.content.Context, + onCall: () -> Unit, +): () -> Unit { + val launcher = + rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) onCall() + } + + return remember(onCall) { + { + if (hasAudioPermission(context)) { + onCall() + } else { + launcher.launch(Manifest.permission.RECORD_AUDIO) + } + } + } +} 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 dadd3b2f6..0e531a8df 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 @@ -73,6 +73,7 @@ fun CallScreen( ) { val callState by callManager.state.collectAsState() val scope = rememberCoroutineScope() + val context = androidx.compose.ui.platform.LocalContext.current when (val state = callState) { is CallState.Idle -> { @@ -89,11 +90,15 @@ fun CallScreen( } is CallState.IncomingCall -> { + val acceptWithPermission = + rememberCallWithPermission(context) { + callController?.acceptIncomingCall(state.sdpOffer) + } IncomingCallUI( callerPubKey = state.callerPubKey, callType = state.callType, accountViewModel = accountViewModel, - onAccept = { callController?.acceptIncomingCall(state.sdpOffer) }, + onAccept = acceptWithPermission, onReject = { scope.launch { callManager.rejectCall() } }, ) } @@ -112,9 +117,18 @@ fun CallScreen( state = state, accountViewModel = accountViewModel, onHangup = { scope.launch { callManager.hangup() } }, - onToggleMute = { callManager.toggleAudioMute() }, - onToggleVideo = { callManager.toggleVideo() }, - onToggleSpeaker = { callManager.toggleSpeaker() }, + onToggleMute = { + callManager.toggleAudioMute() + callController?.setAudioMuted(!state.isAudioMuted) + }, + onToggleVideo = { + callManager.toggleVideo() + callController?.setVideoEnabled(!state.isVideoEnabled) + }, + onToggleSpeaker = { + callManager.toggleSpeaker() + callController?.setSpeakerOn(!state.isSpeakerOn) + }, ) } 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 d2b6a9219..e338e5747 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 @@ -26,12 +26,16 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import com.vitorpamplona.amethyst.ui.call.rememberCallWithPermission import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType @Composable fun ChatroomScreen( @@ -43,6 +47,14 @@ fun ChatroomScreen( accountViewModel: AccountViewModel, nav: INav, ) { + val context = LocalContext.current + val startCall = + rememberCallWithPermission(context) { + val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission + accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) + nav.nav(Route.ActiveCall(callId = "", peerPubKey = peerPubKey)) + } + DisappearingScaffold( isInvertedLayout = true, topBar = { @@ -50,18 +62,7 @@ fun ChatroomScreen( room = roomId, accountViewModel = accountViewModel, nav = nav, - onCallClick = { peerPubKey -> - accountViewModel.callController?.initiateCall( - peerPubKey, - com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VOICE, - ) - nav.nav( - com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall( - callId = "", - peerPubKey = peerPubKey, - ), - ) - }, + onCallClick = { _ -> startCall() }, ) }, accountViewModel = accountViewModel,