From b2584e67056d9ac8875a6c3b4c1490f21ec150bb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 04:53:17 +0000 Subject: [PATCH 1/5] feat: include all group members in p-tags of inner ephemeral events All signaling event types in group calls (answer, hangup, reject, renegotiate) now include p-tags for every group member, matching the pattern already used by CallOfferEvent. This allows signing the inner event once and gift-wrapping it separately for each recipient. - Add Set build() overloads to CallAnswerEvent, CallHangupEvent, CallRejectEvent, and CallRenegotiateEvent - Add createGroupCallAnswer, createGroupRenegotiate factory methods - Fix createGroupHangup to sign once instead of per-peer - Update createGroupReject to accept full member set - Update CallManager to use group methods when in group call - Document group call p-tag convention in NIP-AC spec - Add tests for all group build overloads https://claude.ai/code/session_013h2E7spwHDgSjunqumsYgp --- .../amethyst/commons/call/CallManager.kt | 62 ++++++++++++----- .../quartz/nipACWebRtcCalls/NIP-AC.md | 45 ++++++++++++- .../nipACWebRtcCalls/WebRtcCallFactory.kt | 66 +++++++++++++++---- .../events/CallAnswerEvent.kt | 15 +++++ .../events/CallHangupEvent.kt | 15 +++++ .../events/CallRejectEvent.kt | 15 +++++ .../events/CallRenegotiateEvent.kt | 15 +++++ .../quartz/nipACWebRtcCalls/CallEventsTest.kt | 66 +++++++++++++++++++ 8 files changed, 268 insertions(+), 31 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 6e1a7969fd..cf0b5db761 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -137,27 +137,45 @@ class CallManager( val current = _state.value if (current !is CallState.IncomingCall) return - val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) _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. - val selfNotify = factory.createCallAnswer(sdpAnswer, signer.pubKey, current.callId, signer) - publishEvent(selfNotify.wrap) + if (current.groupMembers.size > 2) { + // Group call: include all members in p-tags, sign once, wrap for each. + // Include self so other devices get notified too. + val allRecipients = current.groupMembers + signer.pubKey + val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer) + result.wraps.forEach { publishEvent(it) } + } else { + val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) + publishEvent(result.wrap) + + // Notify other devices of this user that the call was answered here. + val selfNotify = factory.createCallAnswer(sdpAnswer, signer.pubKey, current.callId, signer) + publishEvent(selfNotify.wrap) + } } suspend fun rejectCall() { val current = _state.value if (current !is CallState.IncomingCall) return - val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED) - publishEvent(result.wrap) - // Notify other devices of this user that the call was rejected here. - val selfNotify = factory.createReject(signer.pubKey, current.callId, signer = signer) - publishEvent(selfNotify.wrap) + if (current.groupMembers.size > 2) { + // Group call: include all members in p-tags, sign once, wrap for each. + // Include self so other devices get notified too. + val allRecipients = current.groupMembers + signer.pubKey + val result = factory.createGroupReject(allRecipients, current.callId, signer = signer) + result.wraps.forEach { publishEvent(it) } + } else { + val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) + publishEvent(result.wrap) + + // Notify other devices of this user that the call was rejected here. + val selfNotify = factory.createReject(signer.pubKey, current.callId, signer = signer) + publishEvent(selfNotify.wrap) + } } fun onCallAnswered(event: CallAnswerEvent) { @@ -283,16 +301,28 @@ class CallManager( suspend fun sendRenegotiation(sdpOffer: String) { val callId = currentCallId() ?: return - val peerPubKey = currentPeerPubKey() ?: return - val result = factory.createRenegotiate(sdpOffer, peerPubKey, callId, signer) - publishEvent(result.wrap) + val peerPubKeys = currentPeerPubKeys() ?: return + + if (peerPubKeys.size > 1) { + val result = factory.createGroupRenegotiate(sdpOffer, peerPubKeys, callId, signer) + result.wraps.forEach { publishEvent(it) } + } else { + val result = factory.createRenegotiate(sdpOffer, peerPubKeys.first(), callId, signer) + publishEvent(result.wrap) + } } suspend fun sendRenegotiationAnswer(sdpAnswer: String) { val callId = currentCallId() ?: return - val peerPubKey = currentPeerPubKey() ?: return - val result = factory.createCallAnswer(sdpAnswer, peerPubKey, callId, signer) - publishEvent(result.wrap) + val peerPubKeys = currentPeerPubKeys() ?: return + + if (peerPubKeys.size > 1) { + val result = factory.createGroupCallAnswer(sdpAnswer, peerPubKeys, callId, signer) + result.wraps.forEach { publishEvent(it) } + } else { + val result = factory.createCallAnswer(sdpAnswer, peerPubKeys.first(), callId, signer) + publishEvent(result.wrap) + } } fun onPeerConnected() { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index 8d282c8078..3fe3689520 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -42,7 +42,7 @@ All signaling events MUST include: | Tag | Description | Required | |---------------|-------------------------------------------------------|----------| -| `p` | Hex pubkey of the recipient | YES | +| `p` | Hex pubkey of the recipient (group calls: one per member) | YES | | `call-id` | UUID identifying the call session | YES | | `expiration` | Unix timestamp ([NIP-40](https://github.com/nostr-protocol/nips/blob/master/40.md)), SHOULD be `created_at + 20` seconds | YES | | `alt` | Human-readable description ([NIP-31](https://github.com/nostr-protocol/nips/blob/master/31.md)) | YES | @@ -251,6 +251,49 @@ Either party may send a `CallHangup` (kind 25053) at any time. The recipient SHO The callee may send a `CallReject` (kind 25054) instead of a `CallAnswer`. The caller SHOULD stop ringing and display a "call rejected" state. +## Group Calls + +Group calls (calls with more than two participants) use the same event kinds but differ in how `p` tags and gift wraps are structured. + +### P-Tag Convention + +In a group call, all signaling events (except ICE candidates, kind 25052) MUST include a `p` tag for **every** group member. This allows each recipient to know the full group composition from any signaling event. + +ICE candidates (kind 25052) remain addressed to a single peer because WebRTC connections are peer-to-peer — each ICE candidate is relevant only to the specific connection it belongs to. + +### Sign Once, Wrap Per Recipient + +Because all group members are listed in the inner event's `p` tags, the event is **signed once** and then gift-wrapped individually for each recipient: + +1. **Build** the signaling event with `p` tags for all group members +2. **Sign** the event once with the sender's key +3. **Gift-wrap** the same signed event separately for each member (each wrap encrypted to that member's pubkey) +4. **Publish** each gift wrap to the corresponding member's relay list + +This is more efficient than signing a separate event per recipient and ensures cryptographic consistency — every member receives the exact same signed inner event. + +### Group Call Offer + +The Call Offer (kind 25050) initiating a group call contains multiple `p` tags: + +```json +{ + "kind": 25050, + "pubkey": "", + "tags": [ + ["p", ""], + ["p", ""], + ["p", ""], + ["call-id", "550e8400-e29b-41d4-a716-446655440000"], + ["call-type", "video"], + ["expiration", "1234567910"], + ["alt", "WebRTC call offer"] + ] +} +``` + +Recipients detect a group call by the presence of multiple `p` tags. The full group is the union of all `p`-tagged pubkeys plus the event's `pubkey` (the caller). + ## Spam Prevention Clients SHOULD implement call filtering: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt index 845d397aeb..e31ccf0b4b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -148,8 +148,29 @@ class WebRtcCallFactory { } /** - * Sends a hangup to every peer in a group call. Each peer receives its - * own gift-wrapped hangup event. + * Creates a call answer for a group call. The signed inner event contains + * `p` tags for **every** member so each recipient knows the full group. + * A separate [GiftWrapEvent] is produced for each member. + */ + suspend fun createGroupCallAnswer( + sdpAnswer: String, + memberPubKeys: Set, + callId: String, + signer: NostrSigner, + ): GroupResult { + val template = CallAnswerEvent.build(sdpAnswer, memberPubKeys, callId) + val signed = signer.sign(template) + val wraps = + memberPubKeys.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. The signed inner event + * contains `p` tags for **every** member so each recipient knows the + * full group. A separate [GiftWrapEvent] is produced for each member. */ suspend fun createGroupHangup( peerPubKeys: Set, @@ -157,33 +178,50 @@ class WebRtcCallFactory { reason: String = "", signer: NostrSigner, ): GroupResult { - // Each peer gets its own signed hangup with the correct `p` tag - // targeting that specific recipient. - var firstSigned: Event? = null + val template = CallHangupEvent.build(peerPubKeys, callId, reason) + val signed = signer.sign(template) val wraps = peerPubKeys.map { pubKey -> - val template = CallHangupEvent.build(pubKey, callId, reason) - val signed = signer.sign(template) - if (firstSigned == null) firstSigned = signed GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) } - return GroupResult(firstSigned!!, wraps) + return GroupResult(signed, wraps) } /** - * Rejects a group call offer. Sends the rejection to the caller and - * notifies self (for multi-device support). + * Rejects a group call offer. The signed inner event contains `p` tags + * for **every** member so each recipient knows the full group. + * A separate [GiftWrapEvent] is produced for each member. */ suspend fun createGroupReject( - callerPubKey: HexKey, + memberPubKeys: Set, callId: String, reason: String = "", signer: NostrSigner, ): GroupResult { - val template = CallRejectEvent.build(callerPubKey, callId, reason) + val template = CallRejectEvent.build(memberPubKeys, callId, reason) val signed = signer.sign(template) val wraps = - listOf(callerPubKey, signer.pubKey).distinct().map { pubKey -> + memberPubKeys.map { pubKey -> + GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + } + return GroupResult(signed, wraps) + } + + /** + * Sends a renegotiation offer to every peer in a group call. The signed + * inner event contains `p` tags for **every** member so each recipient + * knows the full group. A separate [GiftWrapEvent] is produced for each member. + */ + suspend fun createGroupRenegotiate( + sdpOffer: String, + memberPubKeys: Set, + callId: String, + signer: NostrSigner, + ): GroupResult { + val template = CallRenegotiateEvent.build(sdpOffer, memberPubKeys, callId) + val signed = signer.sign(template) + val wraps = + memberPubKeys.map { pubKey -> GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) } return GroupResult(signed, wraps) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt index e36619b457..22f74615e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt @@ -26,6 +26,7 @@ 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.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -63,5 +64,19 @@ class CallAnswerEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + sdpAnswer: String, + memberPubKeys: Set, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpAnswer, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(memberPubKeys) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt index 3251cbc5f4..e0a902301f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt @@ -26,6 +26,7 @@ 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.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -63,5 +64,19 @@ class CallHangupEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + memberPubKeys: Set, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(memberPubKeys) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt index f4e4a62cd8..a8ae74018b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt @@ -26,6 +26,7 @@ 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.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -63,5 +64,19 @@ class CallRejectEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + memberPubKeys: Set, + callId: String, + reason: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, reason, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(memberPubKeys) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt index d4df637abe..e9a25024cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt @@ -26,6 +26,7 @@ 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.pTagIds import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.nip40Expiration.expiration import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallIdTag @@ -63,5 +64,19 @@ class CallRenegotiateEvent( expiration(createdAt + EXPIRATION_SECONDS) initializer() } + + fun build( + sdpOffer: String, + memberPubKeys: Set, + callId: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, sdpOffer, createdAt) { + alt(ALT_DESCRIPTION) + pTagIds(memberPubKeys) + callId(callId) + expiration(createdAt + EXPIRATION_SECONDS) + initializer() + } } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt index 584f2a3c87..7f885612dc 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/CallEventsTest.kt @@ -249,6 +249,72 @@ class CallEventsTest { assertEquals((2000L + CallOfferEvent.EXPIRATION_SECONDS).toString(), expirationTag?.get(1)) } + @Test + fun groupCallAnswerBuildIncludesAllPTags() { + val members = setOf("alice", "bob", "carol") + val template = + CallAnswerEvent.build( + sdpAnswer = "answer-sdp", + memberPubKeys = members, + callId = "group-call-1", + ) + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(members, pTagValues) + } + + @Test + fun groupCallHangupBuildIncludesAllPTags() { + val members = setOf("alice", "bob", "carol") + val template = + CallHangupEvent.build( + memberPubKeys = members, + callId = "group-call-1", + ) + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(members, pTagValues) + } + + @Test + fun groupCallRejectBuildIncludesAllPTags() { + val members = setOf("alice", "bob", "carol") + val template = + CallRejectEvent.build( + memberPubKeys = members, + callId = "group-call-1", + ) + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(members, pTagValues) + } + + @Test + fun groupCallRenegotiateBuildIncludesAllPTags() { + val members = setOf("alice", "bob", "carol") + val template = + CallRenegotiateEvent.build( + sdpOffer = "new-sdp-offer", + memberPubKeys = members, + callId = "group-call-1", + ) + val pTagValues = + template.tags + .filter { it[0] == "p" } + .map { it[1] } + .toSet() + assertEquals(members, pTagValues) + } + @Test fun singleCalleeOfferIsNotGroupCall() { val template = From 7020eb003c812143d5a82cc0c9b95e8d090f79ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 11:57:32 +0000 Subject: [PATCH 2/5] fix: per-peer SDP handling and invitePeer group context SDP payloads (offer, answer, renegotiate) are specific to individual PeerConnections and cannot be shared across peers. This commit: - Adds group-context overloads to createCallOffer, createCallAnswer, and createRenegotiate that include all member p-tags but wrap to a single target peer - Removes createGroupRenegotiate (renegotiation is always per-peer) - Updates sendRenegotiation/sendRenegotiationAnswer to take explicit peerPubKey and use per-peer wrapping with group p-tags - Updates invitePeer to include all existing group members + invitee in p-tags so the new peer sees the full group composition - Documents SDP per-peer vs sign-once distinction in NIP-AC spec https://claude.ai/code/session_013h2E7spwHDgSjunqumsYgp --- .../amethyst/service/call/CallController.kt | 6 +- .../amethyst/commons/call/CallManager.kt | 61 ++++++++++----- .../quartz/nipACWebRtcCalls/NIP-AC.md | 18 ++++- .../nipACWebRtcCalls/WebRtcCallFactory.kt | 78 ++++++++++++++----- 4 files changed, 122 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt index db8d4f1067..1b5ff27255 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/call/CallController.kt @@ -362,13 +362,14 @@ class CallController( private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) { val session = webRtcSession ?: return val sdpOffer = event.sdpOffer() + val peerPubKey = event.pubKey Log.d(TAG) { "Renegotiation offer received, SDP length=${sdpOffer.length}" } scope.launch { session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer)) session.createAnswer { sdp -> scope.launch { - callManager.sendRenegotiationAnswer(sdp.description) + callManager.sendRenegotiationAnswer(sdp.description, peerPubKey) } } } @@ -378,11 +379,12 @@ class CallController( val session = webRtcSession ?: return val state = callManager.state.value if (state !is CallState.Connected && state !is CallState.Connecting) return + val peerPubKey = callManager.currentPeerPubKey() ?: return Log.d(TAG) { "Starting renegotiation" } session.createOffer { sdp -> scope.launch { - callManager.sendRenegotiation(sdp.description) + callManager.sendRenegotiation(sdp.description, peerPubKey) } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index cf0b5db761..08a3ec7798 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -299,30 +299,46 @@ class CallManager( onRenegotiationOfferReceived?.invoke(event) } - suspend fun sendRenegotiation(sdpOffer: String) { + /** + * Sends a renegotiation offer to a specific peer. SDP is per-PeerConnection + * so this is always addressed to a single peer. In group calls the inner + * event includes `p` tags for all members for group context. + */ + suspend fun sendRenegotiation( + sdpOffer: String, + peerPubKey: HexKey, + ) { val callId = currentCallId() ?: return val peerPubKeys = currentPeerPubKeys() ?: return - if (peerPubKeys.size > 1) { - val result = factory.createGroupRenegotiate(sdpOffer, peerPubKeys, callId, signer) - result.wraps.forEach { publishEvent(it) } - } else { - val result = factory.createRenegotiate(sdpOffer, peerPubKeys.first(), callId, signer) - publishEvent(result.wrap) - } + val result = + if (peerPubKeys.size > 1) { + factory.createRenegotiate(sdpOffer, peerPubKey, peerPubKeys, callId, signer) + } else { + factory.createRenegotiate(sdpOffer, peerPubKey, callId, signer) + } + publishEvent(result.wrap) } - suspend fun sendRenegotiationAnswer(sdpAnswer: String) { + /** + * Sends a renegotiation answer to a specific peer. SDP is per-PeerConnection + * so this is always addressed to a single peer. In group calls the inner + * event includes `p` tags for all members for group context. + */ + suspend fun sendRenegotiationAnswer( + sdpAnswer: String, + peerPubKey: HexKey, + ) { val callId = currentCallId() ?: return val peerPubKeys = currentPeerPubKeys() ?: return - if (peerPubKeys.size > 1) { - val result = factory.createGroupCallAnswer(sdpAnswer, peerPubKeys, callId, signer) - result.wraps.forEach { publishEvent(it) } - } else { - val result = factory.createCallAnswer(sdpAnswer, peerPubKeys.first(), callId, signer) - publishEvent(result.wrap) - } + val result = + if (peerPubKeys.size > 1) { + factory.createCallAnswer(sdpAnswer, peerPubKey, peerPubKeys, callId, signer) + } else { + factory.createCallAnswer(sdpAnswer, peerPubKey, callId, signer) + } + publishEvent(result.wrap) } fun onPeerConnected() { @@ -339,7 +355,11 @@ class CallManager( ) } - /** Invites a new peer into the current call by sending them an offer. */ + /** + * Invites a new peer into the current call by sending them an offer. + * The inner event includes `p` tags for all existing group members plus + * the new invitee so they can see the full group composition. + */ suspend fun invitePeer( peerPubKey: HexKey, sdpOffer: String, @@ -347,16 +367,19 @@ class CallManager( val current = _state.value val callId: String val callType: CallType + val existingMembers: Set when (current) { is CallState.Connecting -> { callId = current.callId callType = current.callType + existingMembers = current.peerPubKeys + current.pendingPeerPubKeys _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey) } is CallState.Connected -> { callId = current.callId callType = current.callType + existingMembers = current.allPeerPubKeys _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey) } @@ -365,7 +388,9 @@ class CallManager( } } - val result = factory.createCallOffer(sdpOffer, peerPubKey, callId, callType, signer) + // All group members: existing peers + the new invitee + ourselves + val allMembers = existingMembers + peerPubKey + signer.pubKey + val result = factory.createCallOffer(sdpOffer, peerPubKey, allMembers, callId, callType, signer) publishEvent(result.wrap) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index 3fe3689520..f37ced8b8f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -263,7 +263,7 @@ ICE candidates (kind 25052) remain addressed to a single peer because WebRTC con ### Sign Once, Wrap Per Recipient -Because all group members are listed in the inner event's `p` tags, the event is **signed once** and then gift-wrapped individually for each recipient: +For events whose content is identical for all recipients (hangup, reject), the event is **signed once** and then gift-wrapped individually for each recipient: 1. **Build** the signaling event with `p` tags for all group members 2. **Sign** the event once with the sender's key @@ -272,6 +272,18 @@ Because all group members are listed in the inner event's `p` tags, the event is This is more efficient than signing a separate event per recipient and ensures cryptographic consistency — every member receives the exact same signed inner event. +### Per-Peer SDP with Group P-Tags + +Events carrying SDP payloads (offer, answer, renegotiate) contain session descriptions that are specific to a single `PeerConnection`. In a full-mesh group call, each participant maintains a separate `PeerConnection` per peer, so SDP content differs per connection. + +For these events, the inner event still includes `p` tags for **all** group members (so any recipient can see the full group), but: + +1. **Build** the event with `p` tags for all group members and the per-peer SDP content +2. **Sign** the event (signed per peer, since the SDP content differs) +3. **Gift-wrap** and send **only to the specific peer** the SDP is intended for + +This means offer, answer, and renegotiate events in group calls are signed per-peer but still carry the full group membership in their `p` tags. + ### Group Call Offer The Call Offer (kind 25050) initiating a group call contains multiple `p` tags: @@ -294,6 +306,10 @@ The Call Offer (kind 25050) initiating a group call contains multiple `p` tags: Recipients detect a group call by the presence of multiple `p` tags. The full group is the union of all `p`-tagged pubkeys plus the event's `pubkey` (the caller). +### Inviting New Peers + +To invite a new peer into an active group call, send a Call Offer (kind 25050) with `p` tags listing **all** existing group members plus the new invitee. This allows the invitee to immediately see the full group composition. The SDP in the offer is specific to the new PeerConnection being established, so the wrap is addressed only to the invitee. + ## Spam Prevention Clients SHOULD implement call filtering: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt index e31ccf0b4b..d6d7f7e349 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/WebRtcCallFactory.kt @@ -64,6 +64,26 @@ class WebRtcCallFactory { return Result(signed, wrap) } + /** + * Offer with group context. The inner event includes `p` tags for + * **all** group members so the invitee knows the full group, but the + * SDP is specific to one [PeerConnection][org.webrtc.PeerConnection] + * so the wrap is addressed only to [calleePubKey]. + */ + suspend fun createCallOffer( + sdpOffer: String, + calleePubKey: HexKey, + memberPubKeys: Set, + callId: String, + callType: CallType, + signer: NostrSigner, + ): Result { + val template = CallOfferEvent.build(sdpOffer, memberPubKeys, callId, callType) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = calleePubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + return Result(signed, wrap) + } + suspend fun createCallAnswer( sdpAnswer: String, callerPubKey: HexKey, @@ -76,6 +96,25 @@ class WebRtcCallFactory { return Result(signed, wrap) } + /** + * Answer with group context. The inner event includes `p` tags for + * **all** group members, but the SDP is specific to one + * [PeerConnection][org.webrtc.PeerConnection] so the wrap is addressed + * only to [peerPubKey]. + */ + suspend fun createCallAnswer( + sdpAnswer: String, + peerPubKey: HexKey, + memberPubKeys: Set, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallAnswerEvent.build(sdpAnswer, memberPubKeys, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + return Result(signed, wrap) + } + suspend fun createIceCandidate( candidateJson: String, peerPubKey: HexKey, @@ -124,6 +163,25 @@ class WebRtcCallFactory { return Result(signed, wrap) } + /** + * Renegotiation with group context. The inner event includes `p` tags + * for **all** group members, but the SDP is specific to one + * [PeerConnection][org.webrtc.PeerConnection] so the wrap is addressed + * only to [peerPubKey]. + */ + suspend fun createRenegotiate( + sdpOffer: String, + peerPubKey: HexKey, + memberPubKeys: Set, + callId: String, + signer: NostrSigner, + ): Result { + val template = CallRenegotiateEvent.build(sdpOffer, memberPubKeys, callId) + val signed = signer.sign(template) + val wrap = GiftWrapEvent.create(event = signed, recipientPubKey = peerPubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) + return Result(signed, wrap) + } + // ---- Group call methods (multiple recipients) ---- /** @@ -206,24 +264,4 @@ class WebRtcCallFactory { } return GroupResult(signed, wraps) } - - /** - * Sends a renegotiation offer to every peer in a group call. The signed - * inner event contains `p` tags for **every** member so each recipient - * knows the full group. A separate [GiftWrapEvent] is produced for each member. - */ - suspend fun createGroupRenegotiate( - sdpOffer: String, - memberPubKeys: Set, - callId: String, - signer: NostrSigner, - ): GroupResult { - val template = CallRenegotiateEvent.build(sdpOffer, memberPubKeys, callId) - val signed = signer.sign(template) - val wraps = - memberPubKeys.map { pubKey -> - GiftWrapEvent.create(event = signed, recipientPubKey = pubKey, expirationDelta = WRAP_EXPIRATION_SECONDS) - } - return GroupResult(signed, wraps) - } } From 0dc38de839bae8faeb035b723560740f7269840c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 12:01:34 +0000 Subject: [PATCH 3/5] feat: add recipientPubKeys()/groupMembers() helpers and multi-device docs - Add recipientPubKeys() and groupMembers() helper methods to CallAnswerEvent, CallHangupEvent, CallRejectEvent, and CallRenegotiateEvent (matching the existing pattern in CallOfferEvent) - Document in NIP-AC spec that group calls handle multi-device self-notification implicitly by including the sender's own pubkey in the gift-wrap recipient set https://claude.ai/code/session_013h2E7spwHDgSjunqumsYgp --- .../com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md | 2 ++ .../quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt | 7 +++++++ .../quartz/nipACWebRtcCalls/events/CallHangupEvent.kt | 7 +++++++ .../quartz/nipACWebRtcCalls/events/CallRejectEvent.kt | 7 +++++++ .../quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt | 7 +++++++ 5 files changed, 30 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md index f37ced8b8f..58a30a3654 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/NIP-AC.md @@ -353,6 +353,8 @@ When a user is logged in on multiple devices, all devices will receive and ring These self-notification events use the same `call-id` as the original call and follow the same gift-wrapping rules. Clients receiving a self-addressed answer or reject MUST verify the `call-id` matches the currently ringing call before acting on it. +**Group calls**: In group calls, the sender's own pubkey SHOULD be included in the set of recipients when gift-wrapping answer and reject events. This means the self-notification is implicit — no separate self-addressed event is needed. The same signed inner event (with all group member `p` tags) is simply wrapped to the sender's own pubkey along with all other members. + ### Audio and Media - Clients SHOULD switch `AudioManager` to `MODE_IN_COMMUNICATION` when a call connects and restore to `MODE_NORMAL` when the call ends. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt index 22f74615e7..3a5e892d07 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallAnswerEvent.kt @@ -25,6 +25,7 @@ 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 @@ -46,6 +47,12 @@ class CallAnswerEvent( fun sdpAnswer() = content + /** All pubkeys referenced by `p` tags in this event. */ + fun recipientPubKeys(): Set = tags.mapNotNull(PTag::parseKey).toSet() + + /** All group members: `p`-tagged pubkeys plus the event author. */ + fun groupMembers(): Set = recipientPubKeys().plus(pubKey) + companion object { const val KIND = 25051 const val ALT_DESCRIPTION = "WebRTC call answer" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt index e0a902301f..cd953d18be 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallHangupEvent.kt @@ -25,6 +25,7 @@ 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 @@ -46,6 +47,12 @@ class CallHangupEvent( fun reason() = content.ifEmpty { null } + /** All pubkeys referenced by `p` tags in this event. */ + fun recipientPubKeys(): Set = tags.mapNotNull(PTag::parseKey).toSet() + + /** All group members: `p`-tagged pubkeys plus the event author. */ + fun groupMembers(): Set = recipientPubKeys().plus(pubKey) + companion object { const val KIND = 25053 const val ALT_DESCRIPTION = "WebRTC call hangup" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt index a8ae74018b..aad23a1ceb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRejectEvent.kt @@ -25,6 +25,7 @@ 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 @@ -46,6 +47,12 @@ class CallRejectEvent( fun reason() = content.ifEmpty { null } + /** All pubkeys referenced by `p` tags in this event. */ + fun recipientPubKeys(): Set = tags.mapNotNull(PTag::parseKey).toSet() + + /** All group members: `p`-tagged pubkeys plus the event author. */ + fun groupMembers(): Set = recipientPubKeys().plus(pubKey) + companion object { const val KIND = 25054 const val ALT_DESCRIPTION = "WebRTC call rejection" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt index e9a25024cd..ba41f41beb 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipACWebRtcCalls/events/CallRenegotiateEvent.kt @@ -25,6 +25,7 @@ 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 @@ -46,6 +47,12 @@ class CallRenegotiateEvent( fun sdpOffer() = content + /** All pubkeys referenced by `p` tags in this event. */ + fun recipientPubKeys(): Set = tags.mapNotNull(PTag::parseKey).toSet() + + /** All group members: `p`-tagged pubkeys plus the event author. */ + fun groupMembers(): Set = recipientPubKeys().plus(pubKey) + companion object { const val KIND = 25055 const val ALT_DESCRIPTION = "WebRTC call renegotiation" From 23e8ef61699036c47cff547d8e209398b86fd39b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 12:25:47 +0000 Subject: [PATCH 4/5] feat: hide call buttons in chats with more than 5 members Full-mesh WebRTC group calls degrade at 5+ participants (each participant uploads N-1 streams). Hide the voice and video call buttons in the chat header when the room has more than 5 members. https://claude.ai/code/session_013h2E7spwHDgSjunqumsYgp --- .../ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt index 01189b2a9f..10fe6ef71c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/ChatroomScreen.kt @@ -50,6 +50,7 @@ fun ChatroomScreen( ) { val context = LocalContext.current val isGroupChat = roomId.users.size > 1 + val isCallSupported = roomId.users.size <= 5 val startVoiceCall = rememberCallWithPermission(context) { ActiveCallHolder.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) @@ -80,8 +81,8 @@ fun ChatroomScreen( room = roomId, accountViewModel = accountViewModel, nav = nav, - onCallClick = { _ -> startVoiceCall() }, - onVideoCallClick = { _ -> startVideoCall() }, + onCallClick = if (isCallSupported) ({ _ -> startVoiceCall() }) else null, + onVideoCallClick = if (isCallSupported) ({ _ -> startVideoCall() }) else null, ) }, accountViewModel = accountViewModel, From 1bc94f5a2984331c1a798517e95cdd3df991fd26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 12:35:53 +0000 Subject: [PATCH 5/5] refactor: remove P2P vs group branching in CallManager The group factory methods work correctly with any number of members, including single-peer calls. Remove the if/else branches that duplicated P2P vs group logic in acceptCall, rejectCall, hangup, sendRenegotiation, and sendRenegotiationAnswer. https://claude.ai/code/session_013h2E7spwHDgSjunqumsYgp --- .../amethyst/commons/call/CallManager.kt | 65 ++++--------------- 1 file changed, 14 insertions(+), 51 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt index 08a3ec7798..7c10f9c9cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/call/CallManager.kt @@ -140,20 +140,10 @@ class CallManager( _state.value = CallState.Connecting(current.callId, current.peerPubKeys(), current.callType) cancelTimeout() - if (current.groupMembers.size > 2) { - // Group call: include all members in p-tags, sign once, wrap for each. - // Include self so other devices get notified too. - val allRecipients = current.groupMembers + signer.pubKey - val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer) - result.wraps.forEach { publishEvent(it) } - } else { - val result = factory.createCallAnswer(sdpAnswer, current.callerPubKey, current.callId, signer) - publishEvent(result.wrap) - - // Notify other devices of this user that the call was answered here. - val selfNotify = factory.createCallAnswer(sdpAnswer, signer.pubKey, current.callId, signer) - publishEvent(selfNotify.wrap) - } + // Include all group members + self so other devices get notified too. + val allRecipients = current.groupMembers + signer.pubKey + val result = factory.createGroupCallAnswer(sdpAnswer, allRecipients, current.callId, signer) + result.wraps.forEach { publishEvent(it) } } suspend fun rejectCall() { @@ -162,20 +152,10 @@ class CallManager( transitionToEnded(current.callId, current.peerPubKeys(), EndReason.REJECTED) - if (current.groupMembers.size > 2) { - // Group call: include all members in p-tags, sign once, wrap for each. - // Include self so other devices get notified too. - val allRecipients = current.groupMembers + signer.pubKey - val result = factory.createGroupReject(allRecipients, current.callId, signer = signer) - result.wraps.forEach { publishEvent(it) } - } else { - val result = factory.createReject(current.callerPubKey, current.callId, signer = signer) - publishEvent(result.wrap) - - // Notify other devices of this user that the call was rejected here. - val selfNotify = factory.createReject(signer.pubKey, current.callId, signer = signer) - publishEvent(selfNotify.wrap) - } + // Include all group members + self so other devices get notified too. + val allRecipients = current.groupMembers + signer.pubKey + val result = factory.createGroupReject(allRecipients, current.callId, signer = signer) + result.wraps.forEach { publishEvent(it) } } fun onCallAnswered(event: CallAnswerEvent) { @@ -310,20 +290,14 @@ class CallManager( ) { val callId = currentCallId() ?: return val peerPubKeys = currentPeerPubKeys() ?: return - - val result = - if (peerPubKeys.size > 1) { - factory.createRenegotiate(sdpOffer, peerPubKey, peerPubKeys, callId, signer) - } else { - factory.createRenegotiate(sdpOffer, peerPubKey, callId, signer) - } + val result = factory.createRenegotiate(sdpOffer, peerPubKey, peerPubKeys, callId, signer) publishEvent(result.wrap) } /** * Sends a renegotiation answer to a specific peer. SDP is per-PeerConnection - * so this is always addressed to a single peer. In group calls the inner - * event includes `p` tags for all members for group context. + * so this is always addressed to a single peer. The inner event includes + * `p` tags for all members for group context. */ suspend fun sendRenegotiationAnswer( sdpAnswer: String, @@ -331,13 +305,7 @@ class CallManager( ) { val callId = currentCallId() ?: return val peerPubKeys = currentPeerPubKeys() ?: return - - val result = - if (peerPubKeys.size > 1) { - factory.createCallAnswer(sdpAnswer, peerPubKey, peerPubKeys, callId, signer) - } else { - factory.createCallAnswer(sdpAnswer, peerPubKey, callId, signer) - } + val result = factory.createCallAnswer(sdpAnswer, peerPubKey, peerPubKeys, callId, signer) publishEvent(result.wrap) } @@ -418,13 +386,8 @@ class CallManager( } } - 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) } - } + val result = factory.createGroupHangup(peerPubKeys, callId, signer = signer) + result.wraps.forEach { publishEvent(it) } transitionToEnded(callId, peerPubKeys, EndReason.HANGUP) }