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(
peerPubKey: String,
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 {
val callId = UUID.randomUUID().toString()
@@ -196,7 +210,11 @@ class CallController(
session.createOffer { sdp ->
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
import android.Manifest
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.quartz.utils.Log
private const val TAG = "CallForegroundService"
class CallForegroundService : Service() {
companion object {
@@ -57,16 +63,26 @@ class CallForegroundService : Service() {
ACTION_START -> {
val peerName = intent.getStringExtra(EXTRA_PEER_NAME) ?: "Unknown"
val notification = buildNotification(peerName)
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
0
},
)
val hasAudioPermission =
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
try {
val fgsType =
if (hasAudioPermission && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} 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 -> {
@@ -123,10 +123,10 @@ fun CallScreen(
is CallState.Offering -> {
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 {
CallInProgressUI(
peerPubKey = state.peerPubKey,
peerPubKey = state.peerPubKeys.first(),
statusText = stringRes(R.string.call_calling),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
@@ -155,10 +155,10 @@ fun CallScreen(
is CallState.Connecting -> {
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 {
CallInProgressUI(
peerPubKey = state.peerPubKey,
peerPubKey = state.peerPubKeys.first(),
statusText = stringRes(R.string.call_connecting),
accountViewModel = accountViewModel,
onHangup = { scope.launch { callManager.hangup() } },
@@ -185,7 +185,7 @@ fun CallScreen(
is CallState.Ended -> {
if (!isInPipMode) {
CallInProgressUI(
peerPubKey = state.peerPubKey,
peerPubKey = state.peerPubKeys.first(),
statusText = stringRes(R.string.call_ended),
accountViewModel = accountViewModel,
onHangup = { onCallEnded() },
@@ -433,7 +433,7 @@ private fun ConnectedCallUI(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user ->
LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
@@ -665,7 +665,7 @@ private fun PipConnectedCallUI(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
LoadUser(baseUserHex = state.peerPubKey, accountViewModel = accountViewModel) { user ->
LoadUser(baseUserHex = state.peerPubKeys.first(), accountViewModel = accountViewModel) { user ->
if (user != null) {
ClickableUserPicture(
baseUser = user,
@@ -49,18 +49,27 @@ fun ChatroomScreen(
nav: INav,
) {
val context = LocalContext.current
val isGroupChat = roomId.users.size > 1
val startVoiceCall =
rememberCallWithPermission(context) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
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)
}
val startVideoCall =
rememberCallWithPermission(context, isVideo = true) {
val peerPubKey = roomId.users.firstOrNull() ?: return@rememberCallWithPermission
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)
}
@@ -146,6 +146,34 @@ fun RenderRoomTopBar(
)
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 = {
@@ -69,6 +69,8 @@ class CallManager(
private fun isEventTooOld(event: Event): Boolean = TimeUtils.now() - event.createdAt > MAX_EVENT_AGE_SECONDS
// ---- P2P call initiation ----
suspend fun initiateCall(
calleePubKey: HexKey,
callType: CallType,
@@ -76,11 +78,31 @@ class CallManager(
sdpOffer: String,
) {
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)
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) {
val callerPubKey = event.pubKey
val callId = event.callId() ?: return
@@ -90,10 +112,13 @@ class CallManager(
if (_state.value !is CallState.Idle) return
val groupMembers = event.groupMembers()
_state.value =
CallState.IncomingCall(
callId = callId,
callerPubKey = callerPubKey,
groupMembers = groupMembers,
callType = callType,
sdpOffer = event.sdpOffer(),
)
@@ -105,13 +130,11 @@ class CallManager(
if (current !is CallState.IncomingCall) return
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()
publishEvent(result.wrap)
// 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)
publishEvent(selfNotify.wrap)
}
@@ -121,7 +144,7 @@ class CallManager(
if (current !is CallState.IncomingCall) return
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)
// Notify other devices of this user that the call was rejected here.
@@ -136,7 +159,7 @@ class CallManager(
when (current) {
is CallState.Offering -> {
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()
onAnswerReceived?.invoke(event)
}
@@ -144,7 +167,7 @@ class CallManager(
is CallState.IncomingCall -> {
// Another device of this user answered the call — stop ringing.
if (callId != current.callId) return
transitionToEnded(current.callId, current.callerPubKey, EndReason.ANSWERED_ELSEWHERE)
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.ANSWERED_ELSEWHERE)
}
is CallState.Connected -> {
@@ -166,13 +189,13 @@ class CallManager(
when (current) {
is CallState.Offering -> {
if (callId != current.callId) return
transitionToEnded(current.callId, current.peerPubKey, EndReason.PEER_REJECTED)
transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED)
}
is CallState.IncomingCall -> {
// Another device of this user rejected the call — stop ringing.
if (callId != current.callId) return
transitionToEnded(current.callId, current.callerPubKey, EndReason.REJECTED)
transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED)
}
else -> {
@@ -219,28 +242,28 @@ class CallManager(
_state.value =
CallState.Connected(
callId = current.callId,
peerPubKey = current.peerPubKey,
peerPubKeys = current.peerPubKeys,
callType = current.callType,
startedAtEpoch = TimeUtils.now(),
)
}
suspend fun hangup() {
val peerPubKey: HexKey
val peerPubKeys: Set<HexKey>
val callId: String
when (val current = _state.value) {
is CallState.Offering -> {
peerPubKey = current.peerPubKey
peerPubKeys = current.peerPubKeys
callId = current.callId
}
is CallState.Connecting -> {
peerPubKey = current.peerPubKey
peerPubKeys = current.peerPubKeys
callId = current.callId
}
is CallState.Connected -> {
peerPubKey = current.peerPubKey
peerPubKeys = current.peerPubKeys
callId = current.callId
}
@@ -249,9 +272,14 @@ class CallManager(
}
}
val result = factory.createHangup(peerPubKey, callId, signer = signer)
transitionToEnded(callId, peerPubKey, EndReason.HANGUP)
publishEvent(result.wrap)
if (peerPubKeys.size == 1) {
val result = factory.createHangup(peerPubKeys.first(), callId, signer = signer)
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) {
@@ -267,8 +295,8 @@ class CallManager(
}
if (callId != currentCallId) return
val peerPubKey = event.pubKey
transitionToEnded(callId, peerPubKey, EndReason.PEER_HANGUP)
val peerPubKeys = currentPeerPubKeys() ?: return
transitionToEnded(callId, peerPubKeys, EndReason.PEER_HANGUP)
}
fun onSignalingEvent(event: Event) {
@@ -299,15 +327,22 @@ class CallManager(
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) {
is CallState.Offering -> s.peerPubKey
is CallState.IncomingCall -> s.callerPubKey
is CallState.Connecting -> s.peerPubKey
is CallState.Connected -> s.peerPubKey
is CallState.Offering -> s.peerPubKeys
is CallState.IncomingCall -> s.peerPubKeys()
is CallState.Connecting -> s.peerPubKeys
is CallState.Connected -> s.peerPubKeys
else -> null
}
/** True when the current call has more than one peer. */
fun isGroupCall(): Boolean = (currentPeerPubKeys()?.size ?: 0) > 1
fun reset() {
_state.value = CallState.Idle
cancelTimeout()
@@ -318,10 +353,10 @@ class CallManager(
private fun transitionToEnded(
callId: String,
peerPubKey: HexKey,
peerPubKeys: Set<HexKey>,
reason: EndReason,
) {
_state.value = CallState.Ended(callId, peerPubKey, reason)
_state.value = CallState.Ended(callId, peerPubKeys, reason)
cancelTimeout()
resetJob?.cancel()
resetJob =
@@ -346,13 +381,13 @@ class CallManager(
else -> null
}
if (currentCallId == callId) {
val peerPubKey =
val peerPubKeys =
when (current) {
is CallState.Offering -> current.peerPubKey
is CallState.IncomingCall -> current.callerPubKey
is CallState.Offering -> current.peerPubKeys
is CallState.IncomingCall -> current.peerPubKeys()
else -> return@launch
}
transitionToEnded(callId, peerPubKey, EndReason.TIMEOUT)
transitionToEnded(callId, peerPubKeys, EndReason.TIMEOUT)
}
}
}
@@ -362,3 +397,14 @@ class CallManager(
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(
val callId: String,
val peerPubKey: HexKey,
val peerPubKeys: Set<HexKey>,
val callType: CallType,
) : CallState
data class IncomingCall(
val callId: String,
val callerPubKey: HexKey,
val groupMembers: Set<HexKey>,
val callType: CallType,
val sdpOffer: String,
) : CallState
data class Connecting(
val callId: String,
val peerPubKey: HexKey,
val peerPubKeys: Set<HexKey>,
val callType: CallType,
) : CallState
data class Connected(
val callId: String,
val peerPubKey: HexKey,
val peerPubKeys: Set<HexKey>,
val callType: CallType,
val startedAtEpoch: Long,
) : CallState
data class Ended(
val callId: String,
val peerPubKey: HexKey,
val peerPubKeys: Set<HexKey>,
val reason: EndReason,
) : CallState
}
@@ -33,15 +33,24 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
class WebRtcCallFactory {
/** Result for a single-recipient signaling message (P2P). */
data class Result(
val msg: Event,
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 {
const val WRAP_EXPIRATION_SECONDS = 20L
}
// ---- P2P (single recipient) methods ----
suspend fun createCallOffer(
sdpOffer: String,
calleePubKey: HexKey,
@@ -114,4 +123,65 @@ class WebRtcCallFactory {
val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS)
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.TagArrayBuilder
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.pTagIds
import com.vitorpamplona.quartz.nip31Alts.alt
import com.vitorpamplona.quartz.nip40Expiration.expiration
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag
@@ -50,6 +52,18 @@ class CallOfferEvent(
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 {
const val KIND = 25050
const val ALT_DESCRIPTION = "WebRTC call offer"
@@ -70,5 +84,21 @@ class CallOfferEvent(
expiration(createdAt + EXPIRATION_SECONDS)
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(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)
}
}