feat: enable group calls via gift wraps to each group member

Extends the NIP-AC P2P call signaling to support group calls by
sending individual gift-wrapped offers to each member of the group,
all sharing the same call-id.

- CallOfferEvent: add multi-pubkey build overload, groupMembers(),
  isGroupCall(), and recipientPubKeys() helpers
- WebRtcCallFactory: add GroupResult type, createGroupCallOffer(),
  createGroupHangup(), and createGroupReject() methods
- CallState: change peerPubKey to peerPubKeys (Set<HexKey>) across
  Offering, Connecting, Connected, and Ended states; add groupMembers
  field to IncomingCall
- CallManager: add initiateGroupCall() that creates a single signed
  offer with p tags for every callee and gift-wraps it individually;
  hangup sends to all peers in a group call
- CallController: add initiateGroupCall() entry point
- Tests: add group call offer tests for p tags, call-id, call-type,
  and expiration

https://claude.ai/code/session_01WafqMofFQfWCQA5TcLvHRb
This commit is contained in:
Claude
2026-04-02 22:13:48 +00:00
parent c1ef8792e9
commit f25c1df0e2
7 changed files with 280 additions and 42 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)
}
} }
} }
} }
@@ -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,
@@ -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)
}
} }