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:
@@ -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,
|
||||||
|
|||||||
+76
-30
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+70
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user