Merge pull request #2087 from vitorpamplona/claude/enable-group-calls-hv7v8

Add group call support to WebRTC call system
This commit is contained in:
Vitor Pamplona
2026-04-02 20:00:26 -04:00
committed by GitHub
10 changed files with 347 additions and 56 deletions
@@ -166,6 +166,20 @@ class CallController(
fun initiateCall( fun initiateCall(
peerPubKey: String, peerPubKey: String,
callType: CallType, callType: CallType,
) {
initiateCallInternal(setOf(peerPubKey), callType)
}
fun initiateGroupCall(
peerPubKeys: Set<String>,
callType: CallType,
) {
initiateCallInternal(peerPubKeys, callType)
}
private fun initiateCallInternal(
peerPubKeys: Set<String>,
callType: CallType,
) { ) {
scope.launch { scope.launch {
val callId = UUID.randomUUID().toString() val callId = UUID.randomUUID().toString()
@@ -196,7 +210,11 @@ class CallController(
session.createOffer { sdp -> session.createOffer { sdp ->
scope.launch { scope.launch {
callManager.initiateCall(peerPubKey, callType, callId, sdp.description) if (peerPubKeys.size == 1) {
callManager.initiateCall(peerPubKeys.first(), callType, callId, sdp.description)
} else {
callManager.initiateGroupCall(peerPubKeys, callType, callId, sdp.description)
}
} }
} }
} }
@@ -20,17 +20,23 @@
*/ */
package com.vitorpamplona.amethyst.service.call package com.vitorpamplona.amethyst.service.call
import android.Manifest
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.utils.Log
private const val TAG = "CallForegroundService"
class CallForegroundService : Service() { class CallForegroundService : Service() {
companion object { companion object {
@@ -57,16 +63,26 @@ class CallForegroundService : Service() {
ACTION_START -> { ACTION_START -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown" val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val notification = buildNotification(peerName) val notification = buildNotification(peerName)
ServiceCompat.startForeground( val hasAudioPermission =
this, ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
NOTIFICATION_ID, PackageManager.PERMISSION_GRANTED
notification, try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val fgsType =
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE if (hasAudioPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
} else { ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
0 } else {
}, 0
) }
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, fgsType)
} catch (e: SecurityException) {
Log.e(TAG, "Cannot start microphone foreground service, falling back", e)
try {
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, 0)
} catch (e2: Exception) {
Log.e(TAG, "Foreground service start failed entirely", e2)
stopSelf()
}
}
} }
ACTION_STOP -> { ACTION_STOP -> {
@@ -123,10 +123,10 @@ fun CallScreen(
is CallState.Offering -> { is CallState.Offering -> {
if (isInPipMode) { if (isInPipMode) {
PipCallUI(peerPubKey = state.peerPubKey, statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel) PipCallUI(peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_calling), accountViewModel = accountViewModel)
} else { } else {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKey, peerPubKey = state.peerPubKeys.first(),
statusText = stringRes(R.string.call_calling), statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
@@ -155,10 +155,10 @@ fun CallScreen(
is CallState.Connecting -> { is CallState.Connecting -> {
if (isInPipMode) { if (isInPipMode) {
PipCallUI(peerPubKey = state.peerPubKey, statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel) PipCallUI(peerPubKey = state.peerPubKeys.first(), statusText = stringRes(R.string.call_connecting), accountViewModel = accountViewModel)
} else { } else {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKey, peerPubKey = state.peerPubKeys.first(),
statusText = stringRes(R.string.call_connecting), statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } }, onHangup = { scope.launch { callManager.hangup() } },
@@ -185,7 +185,7 @@ fun CallScreen(
is CallState.Ended -> { is CallState.Ended -> {
if (!isInPipMode) { if (!isInPipMode) {
CallInProgressUI( CallInProgressUI(
peerPubKey = state.peerPubKey, peerPubKey = state.peerPubKeys.first(),
statusText = stringRes(R.string.call_ended), statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel, accountViewModel = accountViewModel,
onHangup = { onCallEnded() }, onHangup = { onCallEnded() },
@@ -433,7 +433,7 @@ private fun ConnectedCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user ->
if (user != null) { if (user != null) {
ClickableUserPicture( ClickableUserPicture(
baseUser = user, baseUser = user,
@@ -665,7 +665,7 @@ private fun PipConnectedCallUI(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
) { ) {
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user -> LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user ->
if (user != null) { if (user != null) {
ClickableUserPicture( ClickableUserPicture(
baseUser = user, baseUser = user,
@@ -49,18 +49,27 @@ fun ChatroomScreen(
nav: INav, nav: INav,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val isGroupChat = roomId.users.size > 1
val startVoiceCall = val startVoiceCall =
rememberCallWithPermission(context) { rememberCallWithPermission(context) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE) if (isGroupChat) {
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VOICE)
} else {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VOICE)
}
CallActivity.launch(context) CallActivity.launch(context)
} }
val startVideoCall = val startVideoCall =
rememberCallWithPermission(context, isVideo = true) { rememberCallWithPermission(context, isVideo = true) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel)
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO) if (isGroupChat) {
accountViewModel.callController?.initiateGroupCall(roomId.users.toSet(), CallType.VIDEO)
} else {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
accountViewModel.callController?.initiateCall(peerPubKey, CallType.VIDEO)
}
CallActivity.launch(context) CallActivity.launch(context)
} }
@@ -146,6 +146,34 @@ fun RenderRoomTopBar(
) )
RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel) RoomNameOnlyDisplay(room, Modifier.padding(start = 10.dp).weight(1f), FontWeight.Normal, accountViewModel)
if (onVideoCallClick != null) {
IconButton(
onClick = { onVideoCallClick(room.users.joinToString(",")) },
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Videocam,
contentDescription = stringRes(R.string.call_video),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
if (onCallClick != null) {
IconButton(
onClick = { onCallClick(room.users.joinToString(",")) },
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = stringRes(R.string.call_voice),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp),
)
}
}
} }
}, },
extendableRow = { extendableRow = {
@@ -69,6 +69,8 @@ class CallManager(
private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS
// ---- P2P call initiation ----
suspend fun initiateCall( suspend fun initiateCall(
calleePubKey: HexKey, calleePubKey: HexKey,
callType: CallType, callType: CallType,
@@ -76,11 +78,31 @@ class CallManager(
sdpOffer: String, sdpOffer: String,
) { ) {
val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer) val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer)
_state.value = CallState.Offering(callId, calleePubKey, callType) _state.value = CallState.Offering(callId, setOf(calleePubKey), callType)
publishEvent(result.wrap) publishEvent(result.wrap)
startTimeout(callId) startTimeout(callId)
} }
// ---- Group call initiation ----
/**
* Initiates a group call. A single [CallOfferEvent] is created with `p`
* tags for every callee and then gift-wrapped individually to each one.
*/
suspend fun initiateGroupCall(
calleePubKeys: Set<HexKey>,
callType: CallType,
callId: String,
sdpOffer: String,
) {
val result = factory.createGroupCallOffer(sdpOffer, calleePubKeys, callId, callType, signer)
_state.value = CallState.Offering(callId, calleePubKeys, callType)
result.wraps.forEach { publishEvent(it) }
startTimeout(callId)
}
// ---- Incoming call handling ----
fun onIncomingCallEvent(event: CallOfferEvent) { fun onIncomingCallEvent(event: CallOfferEvent) {
val callerPubKey = event.pubKey val callerPubKey = event.pubKey
val callId = event.callId() ?: return val callId = event.callId() ?: return
@@ -90,10 +112,13 @@ class CallManager(
if (_state.value !is CallState.Idle) return if (_state.value !is CallState.Idle) return
val groupMembers = event.groupMembers()
_state.value = _state.value =
CallState.IncomingCall( CallState.IncomingCall(
callId = callId, callId = callId,
callerPubKey = callerPubKey, callerPubKey = callerPubKey,
groupMembers = groupMembers,
callType = callType, callType = callType,
sdpOffer = event.sdpOffer(), sdpOffer = event.sdpOffer(),
) )
@@ -105,13 +130,11 @@ class CallManager(
if (current !is CallState.IncomingCall) return if (current !is CallState.IncomingCall) return
val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer)
_state.value = CallState.Connecting(current.callId, current.callerPubKey, current.callType) _state.value = CallState.Connecting(current.callId, current.peerPubKeys(), current.callType)
cancelTimeout() cancelTimeout()
publishEvent(result.wrap) publishEvent(result.wrap)
// Notify other devices of this user that the call was answered here. // Notify other devices of this user that the call was answered here.
// This gift-wraps an answer event to our own pubkey so other logged-in
// devices see it and stop ringing.
val selfNotify = factory.createCallAnswer(sdpAnswer, signer.pubKey, current.callId, signer) val selfNotify = factory.createCallAnswer(sdpAnswer, signer.pubKey, current.callId, signer)
publishEvent(selfNotify.wrap) publishEvent(selfNotify.wrap)
} }
@@ -121,7 +144,7 @@ class CallManager(
if (current !is CallState.IncomingCall) return if (current !is CallState.IncomingCall) return
val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) val result = factory.createReject(current.callerPubKey, current.callId, signer = signer)
transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED) transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
publishEvent(result.wrap) publishEvent(result.wrap)
// Notify other devices of this user that the call was rejected here. // Notify other devices of this user that the call was rejected here.
@@ -136,7 +159,7 @@ class CallManager(
when (current) { when (current) {
is CallState.Offering -> { is CallState.Offering -> {
if (callId != current.callId) return if (callId != current.callId) return
_state.value = CallState.Connecting(current.callId, current.peerPubKey, current.callType) _state.value = CallState.Connecting(current.callId, current.peerPubKeys, current.callType)
cancelTimeout() cancelTimeout()
onAnswerReceived?.invoke(event) onAnswerReceived?.invoke(event)
} }
@@ -144,7 +167,7 @@ class CallManager(
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
// Another device of this user answered the call — stop ringing. // Another device of this user answered the call — stop ringing.
if (callId != current.callId) return if (callId != current.callId) return
transitionToEnded(current.callId, current.callerPubKey, EndReason.ANSWERED_ELSEWHERE) transitionToEnded(current.callId, current.peerPubKeys(), EndReason.ANSWERED_ELSEWHERE)
} }
is CallState.Connected -> { is CallState.Connected -> {
@@ -166,13 +189,13 @@ class CallManager(
when (current) { when (current) {
is CallState.Offering -> { is CallState.Offering -> {
if (callId != current.callId) return if (callId != current.callId) return
transitionToEnded(current.callId, current.peerPubKey, EndReason.PEER_REJECTED) transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED)
} }
is CallState.IncomingCall -> { is CallState.IncomingCall -> {
// Another device of this user rejected the call — stop ringing. // Another device of this user rejected the call — stop ringing.
if (callId != current.callId) return if (callId != current.callId) return
transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED) transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
} }
else -> { else -> {
@@ -219,28 +242,28 @@ class CallManager(
_state.value = _state.value =
CallState.Connected( CallState.Connected(
callId = current.callId, callId = current.callId,
peerPubKey = current.peerPubKey, peerPubKeys = current.peerPubKeys,
callType = current.callType, callType = current.callType,
startedAtEpoch = TimeUtils.now(), startedAtEpoch = TimeUtils.now(),
) )
} }
suspend fun hangup() { suspend fun hangup() {
val peerPubKey: HexKey val peerPubKeys: Set<HexKey>
val callId: String val callId: String
when (val current = _state.value) { when (val current = _state.value) {
is CallState.Offering -> { is CallState.Offering -> {
peerPubKey = current.peerPubKey peerPubKeys = current.peerPubKeys
callId = current.callId callId = current.callId
} }
is CallState.Connecting -> { is CallState.Connecting -> {
peerPubKey = current.peerPubKey peerPubKeys = current.peerPubKeys
callId = current.callId callId = current.callId
} }
is CallState.Connected -> { is CallState.Connected -> {
peerPubKey = current.peerPubKey peerPubKeys = current.peerPubKeys
callId = current.callId callId = current.callId
} }
@@ -249,9 +272,14 @@ class CallManager(
} }
} }
val result = factory.createHangup(peerPubKey, callId, signer = signer) if (peerPubKeys.size == 1) {
transitionToEnded(callId, peerPubKey, EndReason.HANGUP) val result = factory.createHangup(peerPubKeys.first(), callId, signer = signer)
publishEvent(result.wrap) publishEvent(result.wrap)
} else {
val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer)
result.wraps.forEach { publishEvent(it) }
}
transitionToEnded(callId, peerPubKeys, EndReason.HANGUP)
} }
fun onPeerHangup(event: CallHangupEvent) { fun onPeerHangup(event: CallHangupEvent) {
@@ -267,8 +295,8 @@ class CallManager(
} }
if (callId != currentCallId) return if (callId != currentCallId) return
val peerPubKey = event.pubKey val peerPubKeys = currentPeerPubKeys() ?: return
transitionToEnded(callId, peerPubKey, EndReason.PEER_HANGUP) transitionToEnded(callId, peerPubKeys, EndReason.PEER_HANGUP)
} }
fun onSignalingEvent(event: Event) { fun onSignalingEvent(event: Event) {
@@ -299,15 +327,22 @@ class CallManager(
else -> null else -> null
} }
fun currentPeerPubKey(): HexKey? = /** Returns the first peer pubkey (for P2P calls) or null. */
fun currentPeerPubKey(): HexKey? = currentPeerPubKeys()?.firstOrNull()
/** Returns all peer pubkeys for the current call. */
fun currentPeerPubKeys(): Set<HexKey>? =
when (val s = _state.value) { when (val s = _state.value) {
is CallState.Offering -> s.peerPubKey is CallState.Offering -> s.peerPubKeys
is CallState.IncomingCall -> s.callerPubKey is CallState.IncomingCall -> s.peerPubKeys()
is CallState.Connecting -> s.peerPubKey is CallState.Connecting -> s.peerPubKeys
is CallState.Connected -> s.peerPubKey is CallState.Connected -> s.peerPubKeys
else -> null else -> null
} }
/** True when the current call has more than one peer. */
fun isGroupCall(): Boolean = (currentPeerPubKeys()?.size ?: 0) > 1
fun reset() { fun reset() {
_state.value = CallState.Idle _state.value = CallState.Idle
cancelTimeout() cancelTimeout()
@@ -318,10 +353,10 @@ class CallManager(
private fun transitionToEnded( private fun transitionToEnded(
callId: String, callId: String,
peerPubKey: HexKey, peerPubKeys: Set<HexKey>,
reason: EndReason, reason: EndReason,
) { ) {
_state.value = CallState.Ended(callId, peerPubKey, reason) _state.value = CallState.Ended(callId, peerPubKeys, reason)
cancelTimeout() cancelTimeout()
resetJob?.cancel() resetJob?.cancel()
resetJob = resetJob =
@@ -346,13 +381,13 @@ class CallManager(
else -> null else -> null
} }
if (currentCallId == callId) { if (currentCallId == callId) {
val peerPubKey = val peerPubKeys =
when (current) { when (current) {
is CallState.Offering -> current.peerPubKey is CallState.Offering -> current.peerPubKeys
is CallState.IncomingCall -> current.callerPubKey is CallState.IncomingCall -> current.peerPubKeys()
else -> return@launch else -> return@launch
} }
transitionToEnded(callId, peerPubKey, EndReason.TIMEOUT) transitionToEnded(callId, peerPubKeys, EndReason.TIMEOUT)
} }
} }
} }
@@ -362,3 +397,14 @@ class CallManager(
timeoutJob = null timeoutJob = null
} }
} }
/**
* Convenience extension: the peers in an incoming call are all group members
* except the local signer (i.e. ourselves) but since we don't store the
* local pubkey here we return all members except the caller's own pubkey
* is already the callerPubKey field. In practice the set of "peer" keys
* the UI should track is groupMembers minus self, which the controller
* resolves. Here we simply exclude the caller from the recipients set
* and add back the caller, resulting in the full group minus self.
*/
private fun CallState.IncomingCall.peerPubKeys(): Set<HexKey> = groupMembers
@@ -30,33 +30,34 @@ sealed interface CallState {
data class Offering( data class Offering(
val callId: String, val callId: String,
val peerPubKey: HexKey, val peerPubKeys: Set<HexKey>,
val callType: CallType, val callType: CallType,
) : CallState ) : CallState
data class IncomingCall( data class IncomingCall(
val callId: String, val callId: String,
val callerPubKey: HexKey, val callerPubKey: HexKey,
val groupMembers: Set<HexKey>,
val callType: CallType, val callType: CallType,
val sdpOffer: String, val sdpOffer: String,
) : CallState ) : CallState
data class Connecting( data class Connecting(
val callId: String, val callId: String,
val peerPubKey: HexKey, val peerPubKeys: Set<HexKey>,
val callType: CallType, val callType: CallType,
) : CallState ) : CallState
data class Connected( data class Connected(
val callId: String, val callId: String,
val peerPubKey: HexKey, val peerPubKeys: Set<HexKey>,
val callType: CallType, val callType: CallType,
val startedAtEpoch: Long, val startedAtEpoch: Long,
) : CallState ) : CallState
data class Ended( data class Ended(
val callId: String, val callId: String,
val peerPubKey: HexKey, val peerPubKeys: Set<HexKey>,
val reason: EndReason, val reason: EndReason,
) : CallState ) : CallState
} }
@@ -33,15 +33,24 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
class WebRtcCallFactory { class WebRtcCallFactory {
/** Result for a single-recipient signaling message (P2P). */
data class Result( data class Result(
val msg: Event, val msg: Event,
val wrap: GiftWrapEvent, val wrap: GiftWrapEvent,
) )
/** Result for a signaling message gift-wrapped to multiple recipients (group calls). */
data class GroupResult(
val msg: Event,
val wraps: List<GiftWrapEvent>,
)
companion object { companion object {
const val WRAP_EXPIRATION_SECONDS = 20L const val WRAP_EXPIRATION_SECONDS = 20L
} }
// ---- P2P (single recipient) methods ----
suspend fun createCallOffer( suspend fun createCallOffer(
sdpOffer: String, sdpOffer: String,
calleePubKey: HexKey, calleePubKey: HexKey,
@@ -114,4 +123,65 @@ class WebRtcCallFactory {
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
return Result(signed, wrap) return Result(signed, wrap)
} }
// ---- Group call methods (multiple recipients) ----
/**
* Creates a call offer for a group call. The signed inner event contains
* `p` tags for **every** callee so each recipient knows the full group.
* A separate [GiftWrapEvent] is produced for each callee.
*/
suspend fun createGroupCallOffer(
sdpOffer: String,
calleePubKeys: Set<HexKey>,
callId: String,
callType: CallType,
signer: NostrSigner,
): GroupResult {
val template = CallOfferEvent.build(sdpOffer, calleePubKeys, callId, callType)
val signed = signer.sign(template)
val wraps =
calleePubKeys.map { pubKey ->
GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
}
return GroupResult(signed, wraps)
}
/**
* Sends a hangup to every peer in a group call. Each peer receives its
* own gift-wrapped hangup event.
*/
suspend fun createGroupHangup(
peerPubKeys: Set<HexKey>,
callId: String,
reason: String = "",
signer: NostrSigner,
): GroupResult {
val template = CallHangupEvent.build(peerPubKeys.first(), callId, reason)
val signed = signer.sign(template)
val wraps =
peerPubKeys.map { pubKey ->
GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
}
return GroupResult(signed, wraps)
}
/**
* Rejects a group call offer. Sends the rejection to the caller and
* notifies self (for multi-device support).
*/
suspend fun createGroupReject(
callerPubKey: HexKey,
callId: String,
reason: String = "",
signer: NostrSigner,
): GroupResult {
val template = CallRejectEvent.build(callerPubKey, callId, reason)
val signed = signer.sign(template)
val wraps =
listOf(callerPubKey, signer.pubKey).distinct().map { pubKey ->
GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
}
return GroupResult(signed, wraps)
}
} }
@@ -25,7 +25,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag
import com.vitorpamplona.quartz.nip01Core.tags.people.pTagIds
import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
@@ -50,6 +52,18 @@ class CallOfferEvent(
fun sdpOffer() = content fun sdpOffer() = content
/** All pubkeys referenced by `p` tags in this offer. */
fun recipientPubKeys(): Set<HexKey> = tags.mapNotNull(PTag::parseKey).toSet()
/**
* All group members for this call: the `p`-tagged recipients plus the
* event author (caller). For 1-to-1 calls the set has two elements.
*/
fun groupMembers(): Set<HexKey> = recipientPubKeys().plus(pubKey)
/** True when this offer targets more than one callee. */
fun isGroupCall(): Boolean = recipientPubKeys().size > 1
companion object { companion object {
const val KIND = 25050 const val KIND = 25050
const val ALT_DESCRIPTION = "WebRTC call offer" const val ALT_DESCRIPTION = "WebRTC call offer"
@@ -70,5 +84,21 @@ class CallOfferEvent(
expiration(createdAt + EXPIRATION_SECONDS) expiration(createdAt + EXPIRATION_SECONDS)
initializer() initializer()
} }
fun build(
sdpOffer: String,
calleePubKeys: Set<HexKey>,
callId: String,
type: CallType,
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<CallOfferEvent>.() -> Unit = {},
) = eventTemplate(KIND, sdpOffer, createdAt) {
alt(ALT_DESCRIPTION)
pTagIds(calleePubKeys)
callId(callId)
callType(type)
expiration(createdAt + EXPIRATION_SECONDS)
initializer()
}
} }
} }
@@ -188,4 +188,77 @@ class CallEventsTest {
assertEquals("new-sdp-offer", template.content) assertEquals("new-sdp-offer", template.content)
assertEquals(CallRenegotiateEvent.KIND, template.kind) assertEquals(CallRenegotiateEvent.KIND, template.kind)
} }
// ---- Group call offer tests ----
@Test
fun groupCallOfferBuildIncludesAllPTags() {
val callees = setOf("alice", "bob", "carol")
val template =
CallOfferEvent.build(
sdpOffer = "group-sdp",
calleePubKeys = callees,
callId = "group-call-1",
type = CallType.VIDEO,
)
val pTagValues =
template.tags
.filter { it[0] == "p" }
.map { it[1] }
.toSet()
assertEquals(callees, pTagValues)
}
@Test
fun groupCallOfferBuildIncludesCallIdTag() {
val template =
CallOfferEvent.build(
sdpOffer = "sdp",
calleePubKeys = setOf("alice", "bob"),
callId = "group-call-id",
type = CallType.VOICE,
)
val callIdTag = template.tags.firstOrNull { it[0] == "call-id" }
assertEquals("group-call-id", callIdTag?.get(1))
}
@Test
fun groupCallOfferBuildIncludesCallTypeTag() {
val template =
CallOfferEvent.build(
sdpOffer = "sdp",
calleePubKeys = setOf("alice", "bob"),
callId = "id",
type = CallType.VIDEO,
)
val callTypeTag = template.tags.firstOrNull { it[0] == "call-type" }
assertEquals("video", callTypeTag?.get(1))
}
@Test
fun groupCallOfferBuildIncludesExpirationTag() {
val template =
CallOfferEvent.build(
sdpOffer = "sdp",
calleePubKeys = setOf("alice", "bob"),
callId = "id",
type = CallType.VOICE,
createdAt = 2000L,
)
val expirationTag = template.tags.firstOrNull { it[0] == "expiration" }
assertEquals((2000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1))
}
@Test
fun singleCalleeOfferIsNotGroupCall() {
val template =
CallOfferEvent.build(
sdpOffer = "sdp",
calleePubKey = "alice",
callId = "id",
type = CallType.VOICE,
)
val pTags = template.tags.filter { it[0] == "p" }
assertEquals(1, pTags.size)
}
} }