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
This commit is contained in:
@@ -362,13 +362,14 @@ class CallController(
|
|||||||
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
|
private fun onRenegotiationOfferReceived(event: CallRenegotiateEvent) {
|
||||||
val session = webRtcSession ?: return
|
val session = webRtcSession ?: return
|
||||||
val sdpOffer = event.sdpOffer()
|
val sdpOffer = event.sdpOffer()
|
||||||
|
val peerPubKey = event.pubKey
|
||||||
Log.d(TAG) { "Renegotiation offer received, SDP length=${sdpOffer.length}" }
|
Log.d(TAG) { "Renegotiation offer received, SDP length=${sdpOffer.length}" }
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
|
session.setRemoteDescription(SessionDescription(SessionDescription.Type.OFFER, sdpOffer))
|
||||||
session.createAnswer { sdp ->
|
session.createAnswer { sdp ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
callManager.sendRenegotiationAnswer(sdp.description)
|
callManager.sendRenegotiationAnswer(sdp.description, peerPubKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -378,11 +379,12 @@ class CallController(
|
|||||||
val session = webRtcSession ?: return
|
val session = webRtcSession ?: return
|
||||||
val state = callManager.state.value
|
val state = callManager.state.value
|
||||||
if (state !is CallState.Connected && state !is CallState.Connecting) return
|
if (state !is CallState.Connected && state !is CallState.Connecting) return
|
||||||
|
val peerPubKey = callManager.currentPeerPubKey() ?: return
|
||||||
|
|
||||||
Log.d(TAG) { "Starting renegotiation" }
|
Log.d(TAG) { "Starting renegotiation" }
|
||||||
session.createOffer { sdp ->
|
session.createOffer { sdp ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
callManager.sendRenegotiation(sdp.description)
|
callManager.sendRenegotiation(sdp.description, peerPubKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-18
@@ -299,30 +299,46 @@ class CallManager(
|
|||||||
onRenegotiationOfferReceived?.invoke(event)
|
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 callId = currentCallId() ?: return
|
||||||
val peerPubKeys = currentPeerPubKeys() ?: return
|
val peerPubKeys = currentPeerPubKeys() ?: return
|
||||||
|
|
||||||
if (peerPubKeys.size > 1) {
|
val result =
|
||||||
val result = factory.createGroupRenegotiate(sdpOffer, peerPubKeys, callId, signer)
|
if (peerPubKeys.size > 1) {
|
||||||
result.wraps.forEach { publishEvent(it) }
|
factory.createRenegotiate(sdpOffer, peerPubKey, peerPubKeys, callId, signer)
|
||||||
} else {
|
} else {
|
||||||
val result = factory.createRenegotiate(sdpOffer, peerPubKeys.first(), callId, signer)
|
factory.createRenegotiate(sdpOffer, peerPubKey, callId, signer)
|
||||||
publishEvent(result.wrap)
|
}
|
||||||
}
|
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 callId = currentCallId() ?: return
|
||||||
val peerPubKeys = currentPeerPubKeys() ?: return
|
val peerPubKeys = currentPeerPubKeys() ?: return
|
||||||
|
|
||||||
if (peerPubKeys.size > 1) {
|
val result =
|
||||||
val result = factory.createGroupCallAnswer(sdpAnswer, peerPubKeys, callId, signer)
|
if (peerPubKeys.size > 1) {
|
||||||
result.wraps.forEach { publishEvent(it) }
|
factory.createCallAnswer(sdpAnswer, peerPubKey, peerPubKeys, callId, signer)
|
||||||
} else {
|
} else {
|
||||||
val result = factory.createCallAnswer(sdpAnswer, peerPubKeys.first(), callId, signer)
|
factory.createCallAnswer(sdpAnswer, peerPubKey, callId, signer)
|
||||||
publishEvent(result.wrap)
|
}
|
||||||
}
|
publishEvent(result.wrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPeerConnected() {
|
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(
|
suspend fun invitePeer(
|
||||||
peerPubKey: HexKey,
|
peerPubKey: HexKey,
|
||||||
sdpOffer: String,
|
sdpOffer: String,
|
||||||
@@ -347,16 +367,19 @@ class CallManager(
|
|||||||
val current = _state.value
|
val current = _state.value
|
||||||
val callId: String
|
val callId: String
|
||||||
val callType: CallType
|
val callType: CallType
|
||||||
|
val existingMembers: Set<HexKey>
|
||||||
when (current) {
|
when (current) {
|
||||||
is CallState.Connecting -> {
|
is CallState.Connecting -> {
|
||||||
callId = current.callId
|
callId = current.callId
|
||||||
callType = current.callType
|
callType = current.callType
|
||||||
|
existingMembers = current.peerPubKeys + current.pendingPeerPubKeys
|
||||||
_state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey)
|
_state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
is CallState.Connected -> {
|
is CallState.Connected -> {
|
||||||
callId = current.callId
|
callId = current.callId
|
||||||
callType = current.callType
|
callType = current.callType
|
||||||
|
existingMembers = current.allPeerPubKeys
|
||||||
_state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys + peerPubKey)
|
_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)
|
publishEvent(result.wrap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ ICE candidates (kind 25052) remain addressed to a single peer because WebRTC con
|
|||||||
|
|
||||||
### Sign Once, Wrap Per Recipient
|
### 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
|
1. **Build** the signaling event with `p` tags for all group members
|
||||||
2. **Sign** the event once with the sender's key
|
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.
|
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
|
### Group Call Offer
|
||||||
|
|
||||||
The Call Offer (kind 25050) initiating a group call contains multiple `p` tags:
|
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).
|
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
|
## Spam Prevention
|
||||||
|
|
||||||
Clients SHOULD implement call filtering:
|
Clients SHOULD implement call filtering:
|
||||||
|
|||||||
+58
-20
@@ -64,6 +64,26 @@ class WebRtcCallFactory {
|
|||||||
return Result(signed, wrap)
|
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<HexKey>,
|
||||||
|
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(
|
suspend fun createCallAnswer(
|
||||||
sdpAnswer: String,
|
sdpAnswer: String,
|
||||||
callerPubKey: HexKey,
|
callerPubKey: HexKey,
|
||||||
@@ -76,6 +96,25 @@ class WebRtcCallFactory {
|
|||||||
return Result(signed, wrap)
|
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<HexKey>,
|
||||||
|
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(
|
suspend fun createIceCandidate(
|
||||||
candidateJson: String,
|
candidateJson: String,
|
||||||
peerPubKey: HexKey,
|
peerPubKey: HexKey,
|
||||||
@@ -124,6 +163,25 @@ class WebRtcCallFactory {
|
|||||||
return Result(signed, wrap)
|
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<HexKey>,
|
||||||
|
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) ----
|
// ---- Group call methods (multiple recipients) ----
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,24 +264,4 @@ class WebRtcCallFactory {
|
|||||||
}
|
}
|
||||||
return GroupResult(signed, wraps)
|
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<HexKey>,
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user