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
This commit is contained in:
@@ -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
|
||||
|
||||
+25
@@ -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
|
||||
|
||||
+68
@@ -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<StatusBarNotification> = activeNotifications
|
||||
for (notification in notifications) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-12
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user