Merge pull request #2087 from vitorpamplona/claude/enable-group-calls-hv7v8
Add group call support to WebRTC call system
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-10
@@ -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,
|
||||
|
||||
+13
-4
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+28
@@ -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 = {
|
||||
|
||||
+76
-30
@@ -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
|
||||
}
|
||||
|
||||
+70
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user