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 c38d161a8..50913d770 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 @@ -310,8 +310,28 @@ class CallController( } AnswerRouteAction.NO_SESSION -> { - Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee" } - onNewPeerInGroupCall(peerPubKey) + // Unknown peer answering the current call. Two cases: + // + // 1. We are in Connected state — another participant invited a + // new peer mid-call and the invitee is broadcasting their + // acceptance to us. The invitee stays passive, so we MUST + // unconditionally initiate a mesh offer to them. The + // lower-pubkey tiebreaker does NOT apply here because only + // one side (the existing Connected callee) reacts to the + // broadcast answer. + // + // 2. We are still in Connecting state — both callees are + // handshaking in parallel during an initial group call and + // are observing each other's answers. Use the lower-pubkey + // tiebreaker via onNewPeerInGroupCall() to avoid glare, + // since the symmetric peer will apply the same rule. + if (callManager.state.value is CallState.Connected) { + Log.d(TAG) { "Mid-call invite: ${peerPubKey.take(8)} joined — initiating mesh offer" } + scope.launch { createAndOfferToPeer(peerPubKey) } + } else { + Log.d(TAG) { "Answer from unknown peer ${peerPubKey.take(8)} — triggering callee-to-callee" } + onNewPeerInGroupCall(peerPubKey) + } } AnswerRouteAction.IGNORED_WRONG_STATE -> { 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 5b6ab8758..a053271d5 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 @@ -355,12 +355,25 @@ class CallManager( is CallState.Connecting -> { if (callId != current.callId) return - if (answeringPeer in current.pendingPeerPubKeys) { - _state.value = - current.copy( - peerPubKeys = current.peerPubKeys + answeringPeer, - pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, - ) + when { + answeringPeer in current.pendingPeerPubKeys -> { + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + } + + answeringPeer !in current.peerPubKeys -> { + // Mid-call join while we're still handshaking: another + // participant invited a new peer and that peer + // broadcast their acceptance to us. Expand our group + // membership so the UI reflects the new peer. + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + ) + } } // Forward to CallController — it routes to the correct PeerSession // and internally triggers callee-to-callee mesh setup if needed. @@ -378,12 +391,27 @@ class CallManager( is CallState.Connected -> { if (callId != current.callId) return - if (answeringPeer in current.pendingPeerPubKeys) { - _state.value = - current.copy( - peerPubKeys = current.peerPubKeys + answeringPeer, - pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, - ) + when { + answeringPeer in current.pendingPeerPubKeys -> { + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, + ) + } + + answeringPeer !in current.peerPubKeys -> { + // Mid-call join: another participant invited a new + // peer and that peer broadcast their acceptance to us. + // Expand our group membership so the UI reflects the + // new peer. CallController will unconditionally + // initiate a mesh offer to them (the invitee stays + // passive). + _state.value = + current.copy( + peerPubKeys = current.peerPubKeys + answeringPeer, + ) + } } // Forward to CallController — it routes to the correct PeerSession // and internally triggers callee-to-callee mesh setup if needed. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt index ed8c4fb8b..d1034a5e2 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/call/CallManagerTest.kt @@ -1117,6 +1117,180 @@ class CallManagerTest { assertTrue(carol in state.pendingPeerPubKeys, "Invited peer should be in pending set") } + // ======================================================================== + // Mid-Call Invite: existing callees observe the invitee's broadcast answer + // ======================================================================== + + /** + * Bob is already in a Connected group call with Alice. Alice invites Carol. + * Carol's broadcast CallAnswer reaches Bob. Bob's state must expand to + * include Carol in [CallState.Connected.peerPubKeys] and the answer must + * still be forwarded via [CallManager.onAnswerReceived] so the caller-side + * [CallController] can unconditionally initiate a mesh offer to Carol. + */ + @Test + fun midCallInviteAnswerFromUnknownPeerInConnectedExpandsMembership() = + runTest { + val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + + // Bob is in an established 1-1 call with Alice. + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + var forwardedPeer: HexKey? = null + manager.onAnswerReceived = { event -> forwardedPeer = event.pubKey } + + // Alice invited Carol mid-call; Carol broadcasts her acceptance. + // Bob sees a CallAnswer from Carol (unknown peer, same call-id) + // with p-tags covering the whole expanded group {alice, bob, carol}. + val carolAnswer = makeGroupAnswer(from = carol, members = setOf(alice, bob, carol)) + manager.onSignalingEvent(carolAnswer) + + val state = manager.state.value + assertIs(state) + assertTrue(carol in state.peerPubKeys, "Mid-call joiner must be added to peerPubKeys") + assertTrue(alice in state.peerPubKeys, "Existing peer must still be present") + assertEquals(carol, forwardedPeer, "Answer must still be forwarded to CallController") + } + + /** + * Regression: in an initial group call, the callees observing each other's + * answers MUST NOT trip the mid-call expansion branch. The answering peer + * was already part of the group membership set by [acceptCall] (via the + * IncomingCall.groupMembers → Connecting.peerPubKeys transition), so no + * additional insertion should occur. + */ + @Test + fun initialCallAnswerFromKnownPeerDoesNotExpandMembership() = + runTest { + val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + + // Alice calls Bob and Carol as a group. + manager.onSignalingEvent(makeGroupOffer(from = alice, members = setOf(bob, carol))) + assertIs(manager.state.value) + + // Bob accepts. State becomes Connecting(peerPubKeys={alice, carol}). + manager.acceptCall(sdpAnswer) + val connecting = manager.state.value + assertIs(connecting) + assertTrue(alice in connecting.peerPubKeys) + assertTrue(carol in connecting.peerPubKeys) + + val sizeBefore = connecting.peerPubKeys.size + + // Carol — who is already in Bob's tracked membership — answers. + // This is the normal initial-call mesh observation path. + manager.onSignalingEvent(makeGroupAnswer(from = carol, members = setOf(alice, bob, carol))) + + val after = manager.state.value + assertIs(after) + assertEquals(sizeBefore, after.peerPubKeys.size, "Known peer's answer must not grow peerPubKeys") + } + + /** + * Edge case: an existing callee is still in Connecting state (its own ICE + * handshake with the caller hasn't completed yet) when the mid-call + * invitee broadcasts its answer. Membership must still expand so the UI + * shows the new peer. + */ + @Test + fun midCallInviteAnswerFromUnknownPeerInConnectingExpandsMembership() = + runTest { + val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + // Intentionally NOT calling onPeerConnected — we want to stay in + // Connecting for this test. + assertIs(manager.state.value) + + val carolAnswer = makeGroupAnswer(from = carol, members = setOf(alice, bob, carol)) + manager.onSignalingEvent(carolAnswer) + + val state = manager.state.value + assertIs(state) + assertTrue(carol in state.peerPubKeys, "Mid-call joiner must be added while in Connecting") + } + + /** + * Full end-to-end mid-call invite: Alice calls Bob, connects, then invites + * Carol. Verifies the round-trip state on all three CallManagers: + * + * - Alice's pending→connected transition for Carol (caller side) + * - Carol's IncomingCall → Connecting with {alice, bob} as group members + * - Bob's Connected state expanding to include Carol via the broadcast + * answer path + */ + @Test + fun interfaceMidCallInviteFullFlow() = + runTest { + val (aliceManager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + val (bobManager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol)) + val (carolManager, _) = createManager(localPubKey = carol, followedKeys = setOf(alice, bob)) + + // Step 1: Alice calls Bob (1-1). Both reach Connected. + aliceManager.initiateCall(bob, CallType.VIDEO, callId, sdpOffer) + bobManager.onSignalingEvent(makeOffer(from = alice, to = bob, callType = CallType.VIDEO)) + bobManager.acceptCall(sdpAnswer) + aliceManager.onSignalingEvent(makeAnswer(from = bob, to = alice)) + aliceManager.onPeerConnected() + bobManager.onPeerConnected() + assertIs(aliceManager.state.value) + assertIs(bobManager.state.value) + + // Step 2: Alice invites Carol mid-call. + aliceManager.invitePeer(carol, "alice-to-carol-sdp") + val aliceAfterInvite = aliceManager.state.value + assertIs(aliceAfterInvite) + assertTrue(carol in aliceAfterInvite.pendingPeerPubKeys) + + // Step 3: Carol receives the invite offer. Its p-tags cover the + // full expanded group {alice, bob, carol} so Carol sees Bob in her + // group membership from the first event. + val carolOffer = makeGroupOffer(from = alice, members = setOf(alice, bob, carol), callType = CallType.VIDEO) + carolManager.onSignalingEvent(carolOffer) + val carolIncoming = carolManager.state.value + assertIs(carolIncoming) + assertTrue(alice in carolIncoming.groupMembers) + assertTrue(bob in carolIncoming.groupMembers) + + // Step 4: Carol accepts. Her Connecting state must include Bob + // (so later mid-call offers from Bob are handled correctly). + carolManager.acceptCall("carol-answer-sdp") + val carolConnecting = carolManager.state.value + assertIs(carolConnecting) + assertTrue(bob in carolConnecting.peerPubKeys, "Carol's Connecting state must include Bob as a peer") + assertTrue(alice in carolConnecting.peerPubKeys, "Carol's Connecting state must include Alice as a peer") + + // Step 5: Alice receives Carol's answer broadcast. Carol moves + // out of pending into peerPubKeys. + aliceManager.onSignalingEvent( + makeGroupAnswer(from = carol, members = setOf(alice, bob, carol), sdp = "carol-answer-sdp"), + ) + val aliceAfterCarolAnswer = aliceManager.state.value + assertIs(aliceAfterCarolAnswer) + assertTrue(carol in aliceAfterCarolAnswer.peerPubKeys, "Alice should have Carol connected") + assertTrue( + carol !in aliceAfterCarolAnswer.pendingPeerPubKeys, + "Alice should no longer have Carol pending", + ) + + // Step 6: Bob receives Carol's answer broadcast. Bob's state must + // expand to include Carol (mid-call join), and the answer must be + // forwarded so Bob's CallController can initiate a mesh offer. + var bobForwardedAnswer: HexKey? = null + bobManager.onAnswerReceived = { event -> bobForwardedAnswer = event.pubKey } + bobManager.onSignalingEvent( + makeGroupAnswer(from = carol, members = setOf(alice, bob, carol), sdp = "carol-answer-sdp"), + ) + val bobAfterCarolAnswer = bobManager.state.value + assertIs(bobAfterCarolAnswer) + assertTrue(carol in bobAfterCarolAnswer.peerPubKeys, "Bob should add Carol to his membership") + assertEquals(carol, bobForwardedAnswer, "Bob must forward Carol's answer to his CallController") + } + @Test fun interfaceFullP2PCallFlowWithRealSigners() = runTest { 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 502e86755..e079ed18d 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 @@ -404,10 +404,53 @@ Callee A (lower pubkey) Callee B (higher pubkey) ICE candidates for callee-to-callee connections follow the same buffering rules as caller-callee connections. +The "discover during ringing" mechanism only works for the **initial** group call, where every callee rings in parallel and observes each other's `CallAnswer` events during their own `IncomingCall` window. For peers that join **after** the initial call is already established (see [Inviting New Peers](#inviting-new-peers)), the symmetric tiebreaker does not apply and a different rule is used. + ### 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. +#### Mid-Call Mesh Expansion + +When a new peer joins mid-call, the invitee's ringing window happens **after** all existing callees have already answered, so the invitee cannot discover them via the "observe answers during ringing" mechanism. To preserve the full-mesh invariant without introducing new event kinds, clients MUST apply the following asymmetric rule: + +1. **New invitee (passive)**: After accepting a mid-call invite, the invitee sends its `CallAnswer` (kind 25051) gift-wrapped to **every** group member listed in the offer's `p` tags (as with any group acceptance). The invitee MUST NOT initiate any mesh `CallOffer`s after accepting. It only creates additional `PeerConnection`s **reactively**, by handling mid-call `CallOffer`s from existing callees (the same code path that handles any other mid-call offer for the current `call-id`). + +2. **Existing callees (active)**: When a callee in `Connected` state receives a `CallAnswer` for the current `call-id` from a peer that is **not** currently in its tracked group membership, it MUST treat the sender as a mid-call joiner and: + - Add the sender to its group membership. + - **Unconditionally** create a new `PeerConnection`, generate an SDP offer, and send a mesh `CallOffer` (kind 25050) addressed to the sender. The lower-pubkey tiebreaker does NOT apply in this case — only existing Connected callees react to the broadcast answer, so there is exactly one initiator per (existing callee, new invitee) pair and glare is structurally impossible. + +3. **Callees still in `Connecting` state**: If an existing callee is still handshaking with the caller when a mid-call invitee's broadcast answer arrives, it SHOULD also add the sender to its tracked group membership so the UI reflects the expansion, but MAY defer the mesh offer to the new peer until it reaches `Connected`. Implementations that initiate the mesh offer immediately (while still in `Connecting`) are also conformant — the target invitee processes it via the normal mid-call offer handler. + +The asymmetry (invitee passive, existing callees active) is what distinguishes this from the initial-call mesh setup. In the initial call, both sides observe each other's answers during parallel ringing, so the symmetric lower-pubkey tiebreaker is needed to avoid glare. In a mid-call invite, only the existing callees see a new peer appear (via the broadcast answer), so they unilaterally become the initiators. + +``` +Caller A Existing callee B Existing callee C New invitee D + | | | | + |-- CallOffer (invite) ------------------------------------------------------>| + | (p-tags: A, B, C, D; wrapped only to D) | + | | | | + | | | [D rings → accepts] + |<-- CallAnswer ------------|-----------------------|-- (broadcast) ----------| + | |<----------------------|-- (broadcast) ----------| + | | |<- (broadcast) ----------| + | | | | + | [A: D was pending, | [B: D is new → | [C: D is new → | + | move to peerPubKeys] | add to group, | add to group, | + | | initiate mesh] | initiate mesh] | + | | | | + | |-- mesh CallOffer ------------------------------>| + | | |-- mesh CallOffer ----->| + | | | | + | | | [D: mid-call offer + | | | handler creates + | | | PCs for B and C] + | |<-- mesh CallAnswer --------------------------- | + | | |<-- mesh CallAnswer ----| + | | | | + |====== Full mesh: A↔B, A↔C, A↔D, B↔C, B↔D, C↔D =========================== | +``` + ### Partial Disconnects When a peer's ICE connection fails or they send a hangup in a group call, clients MUST close only that peer's `PeerConnection` and continue the call with remaining peers. The call ends only when all peers have disconnected.