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:
Claude
2026-04-02 00:35:24 +00:00
parent 0688a46604
commit 8114739166
6 changed files with 203 additions and 16 deletions
@@ -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
@@ -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
@@ -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)
},
)
}
@@ -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,