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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.media.AudioManager
|
||||||
import com.vitorpamplona.amethyst.commons.call.CallManager
|
import com.vitorpamplona.amethyst.commons.call.CallManager
|
||||||
import com.vitorpamplona.amethyst.commons.call.CallState
|
import com.vitorpamplona.amethyst.commons.call.CallState
|
||||||
|
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
|
import com.vitorpamplona.quartz.nipACWebRtcCalls.WebRtcCallFactory
|
||||||
@@ -140,6 +142,19 @@ class CallController(
|
|||||||
candidates.forEach { session.addIceCandidate(it) }
|
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() {
|
fun hangup() {
|
||||||
scope.launch { callManager.hangup() }
|
scope.launch { callManager.hangup() }
|
||||||
cleanup()
|
cleanup()
|
||||||
@@ -147,6 +162,7 @@ class CallController(
|
|||||||
|
|
||||||
fun cleanup() {
|
fun cleanup() {
|
||||||
stopForegroundService()
|
stopForegroundService()
|
||||||
|
NotificationUtils.cancelCallNotification(context)
|
||||||
webRtcSession?.dispose()
|
webRtcSession?.dispose()
|
||||||
webRtcSession = null
|
webRtcSession = null
|
||||||
currentCallId = 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.baseEvent.BaseChessEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent
|
||||||
import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent
|
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.Log
|
||||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
@@ -138,6 +139,10 @@ class EventNotificationConsumer(
|
|||||||
is LiveChessMoveEvent -> {
|
is LiveChessMoveEvent -> {
|
||||||
notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn)
|
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 =
|
fun notificationManager(): NotificationManager =
|
||||||
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
|
ContextCompat.getSystemService(applicationContext, NotificationManager::class.java)
|
||||||
as NotificationManager
|
as NotificationManager
|
||||||
|
|||||||
+68
@@ -47,11 +47,14 @@ object NotificationUtils {
|
|||||||
private var zapChannel: NotificationChannel? = null
|
private var zapChannel: NotificationChannel? = null
|
||||||
private var reactionChannel: NotificationChannel? = null
|
private var reactionChannel: NotificationChannel? = null
|
||||||
private var chessChannel: NotificationChannel? = null
|
private var chessChannel: NotificationChannel? = null
|
||||||
|
private var callChannel: NotificationChannel? = null
|
||||||
|
|
||||||
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION"
|
||||||
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION"
|
||||||
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
|
private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION"
|
||||||
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
|
private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION"
|
||||||
|
private const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
|
||||||
|
private const val CALL_NOTIFICATION_ID = 0x50000
|
||||||
|
|
||||||
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION"
|
||||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
||||||
@@ -510,6 +513,71 @@ object NotificationUtils {
|
|||||||
notify(summaryId, summaryBuilder.build())
|
notify(summaryId, summaryBuilder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
|
||||||
|
if (callChannel != null) return callChannel!!
|
||||||
|
|
||||||
|
callChannel =
|
||||||
|
NotificationChannel(
|
||||||
|
CALL_CHANNEL_ID,
|
||||||
|
"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 {
|
private fun NotificationManager.isDuplicate(notId: Int): Boolean {
|
||||||
val notifications: Array<StatusBarNotification> = activeNotifications
|
val notifications: Array<StatusBarNotification> = activeNotifications
|
||||||
for (notification in notifications) {
|
for (notification in notifications) {
|
||||||
|
|||||||
@@ -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 callState by callManager.state.collectAsState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
|
||||||
when (val state = callState) {
|
when (val state = callState) {
|
||||||
is CallState.Idle -> {
|
is CallState.Idle -> {
|
||||||
@@ -89,11 +90,15 @@ fun CallScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is CallState.IncomingCall -> {
|
is CallState.IncomingCall -> {
|
||||||
|
val acceptWithPermission =
|
||||||
|
rememberCallWithPermission(context) {
|
||||||
|
callController?.acceptIncomingCall(state.sdpOffer)
|
||||||
|
}
|
||||||
IncomingCallUI(
|
IncomingCallUI(
|
||||||
callerPubKey = state.callerPubKey,
|
callerPubKey = state.callerPubKey,
|
||||||
callType = state.callType,
|
callType = state.callType,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onAccept = { callController?.acceptIncomingCall(state.sdpOffer) },
|
onAccept = acceptWithPermission,
|
||||||
onReject = { scope.launch { callManager.rejectCall() } },
|
onReject = { scope.launch { callManager.rejectCall() } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -112,9 +117,18 @@ fun CallScreen(
|
|||||||
state = state,
|
state = state,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
onHangup = { scope.launch { callManager.hangup() } },
|
onHangup = { scope.launch { callManager.hangup() } },
|
||||||
onToggleMute = { callManager.toggleAudioMute() },
|
onToggleMute = {
|
||||||
onToggleVideo = { callManager.toggleVideo() },
|
callManager.toggleAudioMute()
|
||||||
onToggleSpeaker = { callManager.toggleSpeaker() },
|
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.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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.layouts.DisappearingScaffold
|
||||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
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.AccountViewModel
|
||||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar
|
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.header.RenderRoomTopBar
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||||
|
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatroomScreen(
|
fun ChatroomScreen(
|
||||||
@@ -43,6 +47,14 @@ fun ChatroomScreen(
|
|||||||
accountViewModel: AccountViewModel,
|
accountViewModel: AccountViewModel,
|
||||||
nav: INav,
|
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(
|
DisappearingScaffold(
|
||||||
isInvertedLayout = true,
|
isInvertedLayout = true,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -50,18 +62,7 @@ fun ChatroomScreen(
|
|||||||
room = roomId,
|
room = roomId,
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
onCallClick = { peerPubKey ->
|
onCallClick = { _ -> startCall() },
|
||||||
accountViewModel.callController?.initiateCall(
|
|
||||||
peerPubKey,
|
|
||||||
com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VOICE,
|
|
||||||
)
|
|
||||||
nav.nav(
|
|
||||||
com.vitorpamplona.amethyst.ui.navigation.routes.Route.ActiveCall(
|
|
||||||
callId = "",
|
|
||||||
peerPubKey = peerPubKey,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
|
|||||||
Reference in New Issue
Block a user