From 0fbe61274aa24369e37f1d3aadd4e6eb3df9fdd3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 22:16:17 +0000 Subject: [PATCH 1/4] fix(call): release WebRTC resources synchronously on CallActivity teardown The normal cleanup path routes through CallManager -> state=Ended -> the state-collector in viewModelScope -> CallController.cleanup(). If the Activity is destroyed before the collector finishes (task removal, PiP dismissal, system kill), the async path can leave PeerConnections, the camera capturer, the proximity wake lock, and the audio-mode change alive until AccountViewModel.onCleared runs later. CallActivity.onDestroy / onStop now call CallController.cleanup() directly as a synchronous safety net, in addition to publishing the hangup/reject signaling on a detached scope. To make that safe, CallController.cleanup() is now truly idempotent within a call session: the cleanedUp flag stays sticky once set and is re-armed only when a new call starts via initiateGroupCall() or acceptIncomingCall(). This prevents a second sequential cleanup() call from double-disposing WebRTC objects when both the state-collector path and the Activity safety net run. https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB --- .../amethyst/service/call/CallController.kt | 22 ++++++++++- .../amethyst/ui/call/CallActivity.kt | 37 +++++++++++++++---- 2 files changed, 50 insertions(+), 9 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 6132fed28..c38d161a8 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 @@ -212,6 +212,9 @@ class CallController( val callId = UUID.randomUUID().toString() _errorMessage.value = null + // A new call session begins — arm cleanup() so it will run again + // for this session's Ended transition. + cleanedUp.set(false) applyCallSettings() try { withContext(Dispatchers.IO) { mediaManager.initialize(callType) } @@ -258,6 +261,9 @@ class CallController( scope.launch { _errorMessage.value = null + // A new call session begins — arm cleanup() so it will run again + // for this session's Ended transition. + cleanedUp.set(false) applyCallSettings() try { withContext(Dispatchers.IO) { mediaManager.initialize(state.callType) } @@ -612,6 +618,17 @@ class CallController( // ---- Cleanup ---- + /** + * Releases all WebRTC, audio, and foreground-service resources for the + * current call session. + * + * Idempotent within a call session: calling this multiple times in a row + * (e.g. once from the Ended state collector and once from [CallActivity]'s + * onDestroy safety net) executes the body at most once. The guard is + * re-armed when a new call starts via [initiateGroupCall] or + * [acceptIncomingCall], so subsequent call sessions still clean up + * correctly. + */ fun cleanup() { if (!cleanedUp.compareAndSet(false, true)) return unregisterNetworkCallback() @@ -643,7 +660,10 @@ class CallController( videoPausedByProximity = false videoSenders.clear() pendingRenegotiation.clear() - cleanedUp.set(false) + // NOTE: cleanedUp intentionally stays true here so that a second, + // sequential cleanup() in the same call session is a no-op. The flag + // is re-armed in initiateGroupCall() / acceptIncomingCall() when a new + // call session begins. } // ---- Foreground service ---- diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt index 3a1fbb8ae..e6560b7bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallActivity.kt @@ -202,28 +202,40 @@ class CallActivity : AppCompatActivity() { if (wasInPipMode && !isInPictureInPictureMode) { hangupInitiated = true val manager = CallSessionBridge.callManager + val controller = CallSessionBridge.callController val state = manager?.state?.value if (state is CallState.Connected || state is CallState.Connecting || state is CallState.Offering) { + // Publish the hangup signaling event on a detached scope so + // it survives activity teardown. CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { manager.hangup() - finishAndRemoveTask() } - } else { - finishAndRemoveTask() } + // Synchronously release all WebRTC/audio resources before the + // activity is torn down so we don't leak a PeerConnection, + // camera capturer, wake lock, or audio mode change. cleanup() is + // idempotent within a call session, so the state-collector path + // triggered by hangup() above will be a no-op. + controller?.cleanup() + finishAndRemoveTask() } } override fun onDestroy() { unregisterPipReceiver() + val manager = CallSessionBridge.callManager + val controller = CallSessionBridge.callController + // Safety net: if the Activity is destroyed while a call is still - // ringing/offering, ensure the call is hung up so audio stops. - // Skip if onStop already initiated the hangup to avoid double signaling. - if (!hangupInitiated) { - val manager = CallSessionBridge.callManager - when (manager?.state?.value) { + // ringing/offering, publish the reject/hangup signaling so the other + // side stops ringing. Skip if onStop already initiated the hangup to + // avoid double signaling. + if (!hangupInitiated && manager != null) { + when (manager.state.value) { is CallState.IncomingCall -> { + // Publish on a detached scope so the reject event goes out + // even after the activity is gone. CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { manager.rejectCall() } @@ -242,6 +254,15 @@ class CallActivity : AppCompatActivity() { } } + // Ultimate safety net: synchronously release all WebRTC/audio + // resources before the Activity reference goes away. The normal + // Ended → state-collector → cleanup() path runs asynchronously on + // viewModelScope and is not guaranteed to finish before this method + // returns. CallController.cleanup() is idempotent within a call + // session, so calling it here in addition to the state-collector path + // is safe. + controller?.cleanup() + super.onDestroy() } From d087e1ac13a8e8acd9b6321a4b6c44e9acba6601 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 22:54:07 +0000 Subject: [PATCH 2/4] feat(call): full-mesh setup for mid-call invites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an existing group call participant invites a new peer via CallController.invitePeer(), the caller connects to the invitee via the normal offer/answer flow, but the other existing callees were never establishing their own PeerConnections to the new peer — breaking the full-mesh invariant. Root cause: the NIP-AC "callee-to-callee mesh setup" mechanism relies on each callee observing the others' CallAnswers during its own ringing window and applying a symmetric "lower pubkey initiates" tiebreaker. A mid-call invitee's ringing window happens after all existing callees have already answered, so the invitee's discoveredCalleePeers set is empty. Worse, if the invitee's pubkey is higher than an existing callee's, that callee also stays passive (waiting for the invitee to initiate), and no mesh connection is ever attempted between them. Fix — break the symmetry on mid-call invites: - CallManager.onCallAnswered now expands peerPubKeys when an answer arrives in Connected (or Connecting) state from a peer that is not yet in the tracked group membership. This keeps the UI and state consistent with the expanded group and gives CallController a clear hook via onAnswerReceived. - CallController.onCallAnswerReceived splits the NO_SESSION case: * Connected state → mid-call invite. Unconditionally initiate a mesh CallOffer to the new peer. The invitee stays passive, so exactly one side initiates per pair and glare is structurally impossible. * Connecting state → initial-call mesh observation. Keep the existing lower-pubkey tiebreaker via onNewPeerInGroupCall to avoid glare with the symmetric peer. This fix uses the existing event kinds (CallOffer 25050 and CallAnswer 25051) — no new kinds or tags are required. NIP-AC.md is updated with a new "Mid-Call Mesh Expansion" subsection under "Inviting New Peers" documenting the asymmetric rule and a full-flow diagram. Tests in CallManagerTest cover: - Mid-call invitee's broadcast answer expands existing callee's peerPubKeys in Connected state. - Same expansion works in Connecting state (existing callee still handshaking with caller when the invitee joins). - Initial-call answers from already-tracked peers do NOT trigger the expansion branch (regression guard). - Full end-to-end 3-way flow exercising Alice, Bob, Carol managers. https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB --- .../amethyst/service/call/CallController.kt | 24 ++- .../amethyst/commons/call/CallManager.kt | 52 ++++-- .../amethyst/commons/call/CallManagerTest.kt | 174 ++++++++++++++++++ .../quartz/nipACWebRtcCalls/NIP-AC.md | 43 +++++ 4 files changed, 279 insertions(+), 14 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 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. From b9d6cefbeb11c324a653231b538b1e546d3aa1b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 23:23:42 +0000 Subject: [PATCH 3/4] feat(call): per-peer 30s invite timeout and per-peer status in video grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that tighten how the caller-side UX handles slow or unresponsive callees in a group call: 1. Per-peer 30-second invite timeout Previously the caller used a single 60-second call-wide timeout: if *any* peer answered within the window the timer was cancelled and slow peers could remain "ringing" indefinitely. For a mid-call invite there was no timeout at all — the invitee stayed in pendingPeerPubKeys forever, burning a PeerConnection on the caller side and keeping the invitee's device ringing. CallManager now schedules an independent 30-second timer for every peer in pendingPeerPubKeys (or Offering.peerPubKeys in the initial offer phase). The timer is started when the peer is added to pending — in beginOffering, initiateCall and invitePeer — and is cancelled when the peer answers (onCallAnswered), rejects (onCallRejected) or hangs up (onPeerHangup). On expiry the peer is dropped from the group, a CallHangup is published to them so their device stops ringing, and onPeerLeft fires so CallController can dispose the per-peer PeerConnection. If the drop leaves the caller with zero connected and zero pending peers, the call ends with EndReason.TIMEOUT. Callee ringing still uses the 60-second CALL_TIMEOUT_MS in onIncomingCallEvent; the two timers now serve distinct roles. New constant CallManager.PEER_INVITE_TIMEOUT_MS = 30_000L. NIP-AC.md "Event Lifecycle" documents both timers. 2. Per-peer "Calling..." status in the video grid The shared "Waiting for others to join…" banner across the top of ConnectedCallUI is removed. PeerVideoGrid now takes a pendingPeerPubKeys set and, for each peer still pending, routes rendering through PeerAvatarCell with a "Calling…" status line under the username — so it's obvious *which* participants the call is waiting on, not just *that* it's waiting. A peer in pending never shows as a video cell even if a stale track is still in the map, because they haven't actually answered yet. The voice-only path in ConnectedCallUI now also uses PeerVideoGrid (with empty tracks/active-video) instead of the single GroupCallPictures + GroupCallNames stack, so the per-peer status behavior is consistent for audio calls. Tests added in CallManagerTest: - perPeerTimeoutEndsP2PCallWhenBobNeverAnswers - perPeerTimeoutDropsSlowCalleeFromGroupCall - perPeerTimeoutIsCancelledOnAnswer - perPeerTimeoutDropsMidCallInviteeWhenNoAnswer - perPeerTimeoutIsCancelledOnReject - perPeerTimeoutEndsCallWhenAllCalleesIgnore All existing CallManagerTest cases still pass unchanged. https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB --- .../amethyst/ui/call/CallWidgets.kt | 25 ++- .../amethyst/ui/call/ConnectedCallUI.kt | 44 ++--- .../amethyst/commons/call/CallManager.kt | 149 ++++++++++++++- .../amethyst/commons/call/CallManagerTest.kt | 180 ++++++++++++++++++ .../quartz/nipACWebRtcCalls/NIP-AC.md | 3 +- 5 files changed, 362 insertions(+), 39 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt index c606bd60f..8e64d01ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/CallWidgets.kt @@ -48,11 +48,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.BaseUserPicture import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser +import com.vitorpamplona.amethyst.ui.stringRes import org.webrtc.RendererCommon import org.webrtc.SurfaceViewRenderer import org.webrtc.VideoTrack @@ -104,6 +106,7 @@ fun VideoRenderer( @Composable fun PeerVideoGrid( peerPubKeys: Set, + pendingPeerPubKeys: Set, remoteVideoTracks: Map, activePeerVideos: Set, eglBase: org.webrtc.EglBase?, @@ -115,7 +118,8 @@ fun PeerVideoGrid( if (peers.size == 1) { val peerKey = peers[0] val track = remoteVideoTracks[peerKey] - if (track != null && peerKey in activePeerVideos) { + val isPending = peerKey in pendingPeerPubKeys + if (track != null && peerKey in activePeerVideos && !isPending) { VideoRenderer( videoTrack = track, eglBase = eglBase, @@ -126,6 +130,7 @@ fun PeerVideoGrid( PeerAvatarCell( peerPubKey = peerKey, accountViewModel = accountViewModel, + statusText = if (isPending) stringRes(R.string.call_calling) else null, modifier = modifier, ) } @@ -143,18 +148,21 @@ fun PeerVideoGrid( ) { row.forEach { peerKey -> val track = remoteVideoTracks[peerKey] - if (track != null && peerKey in activePeerVideos) { + val isPending = peerKey in pendingPeerPubKeys + val cellModifier = Modifier.weight(1f).fillMaxHeight() + if (track != null && peerKey in activePeerVideos && !isPending) { VideoRenderer( videoTrack = track, eglBase = eglBase, - modifier = Modifier.weight(1f).fillMaxHeight(), + modifier = cellModifier, mirror = false, ) } else { PeerAvatarCell( peerPubKey = peerKey, accountViewModel = accountViewModel, - modifier = Modifier.weight(1f).fillMaxHeight(), + statusText = if (isPending) stringRes(R.string.call_calling) else null, + modifier = cellModifier, ) } } @@ -172,6 +180,7 @@ fun PeerAvatarCell( peerPubKey: String, accountViewModel: AccountViewModel, modifier: Modifier = Modifier, + statusText: String? = null, ) { Box( modifier = modifier.background(Color.DarkGray), @@ -197,6 +206,14 @@ fun PeerAvatarCell( ) } } + if (statusText != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = statusText, + color = Color.White.copy(alpha = 0.7f), + fontSize = 13.sp, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt index 700ddab1e..69defdf4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/call/ConnectedCallUI.kt @@ -145,6 +145,7 @@ fun ConnectedCallUI( if (hasActiveVideo) { PeerVideoGrid( peerPubKeys = otherMembers, + pendingPeerPubKeys = state.pendingPeerPubKeys, remoteVideoTracks = remoteVideoTracks, activePeerVideos = activePeerVideos, eglBase = callController?.getEglBase(), @@ -179,35 +180,27 @@ fun ConnectedCallUI( .windowInsetsPadding(WindowInsets.statusBars) .padding(top = 16.dp), ) - - if (state.pendingPeerPubKeys.isNotEmpty()) { - Text( - text = stringRes(R.string.call_waiting_for_others), - color = Color.White.copy(alpha = 0.5f), - fontSize = 13.sp, - modifier = - Modifier - .align(Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.statusBars) - .padding(top = 38.dp), - ) - } } else { + // Voice-only path: render the same peer grid so each pending + // callee shows their individual "Calling…" status rather than a + // single shared banner. Tracks and active-video sets are empty + // here, so every cell falls through to PeerAvatarCell. + val emptyTracks = remember { emptyMap() } + val emptyActive = remember { emptySet() } + Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - GroupCallPictures( + PeerVideoGrid( peerPubKeys = otherMembers, - size = 120.dp, + pendingPeerPubKeys = state.pendingPeerPubKeys, + remoteVideoTracks = emptyTracks, + activePeerVideos = emptyActive, + eglBase = null, accountViewModel = accountViewModel, - ) - Spacer(modifier = Modifier.height(16.dp)) - GroupCallNames( - peerPubKeys = otherMembers, - accountViewModel = accountViewModel, - textColor = Color.White, + modifier = Modifier.weight(1f).fillMaxWidth(), ) Spacer(modifier = Modifier.height(8.dp)) Text( @@ -215,14 +208,7 @@ fun ConnectedCallUI( color = Color.White.copy(alpha = 0.7f), fontSize = 16.sp, ) - if (state.pendingPeerPubKeys.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = stringRes(R.string.call_waiting_for_others), - color = Color.White.copy(alpha = 0.5f), - fontSize = 13.sp, - ) - } + Spacer(modifier = Modifier.height(24.dp)) } } 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 a053271d5..106cf40cf 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 @@ -79,6 +79,14 @@ class CallManager( private var resetJob: Job? = null private val processedEventIds = LinkedHashSet() + /** Per-peer invite timeout jobs. A separate 30-second timer is scheduled + * for each peer we are waiting on (initial group-call offerees and + * mid-call invitees) so that slow/unavailable peers can be dropped + * individually without affecting the rest of the call. The timer is + * cancelled when the peer answers, rejects, or hangs up — or when the + * whole call ends. */ + private val perPeerTimeoutJobs = mutableMapOf() + /** Call IDs for which we have seen a hangup, reject, or answer-elsewhere * signal. Checked before transitioning to [CallState.IncomingCall] so * that stale offer events replayed by relays after an app restart do not @@ -95,7 +103,8 @@ class CallManager( private val discoveredCalleePeers = mutableSetOf() companion object { - const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout + const val CALL_TIMEOUT_MS = 60_000L // 60 seconds ringing timeout (callee side) + const val PEER_INVITE_TIMEOUT_MS = 30_000L // 30 seconds per-peer invite timeout (caller side) const val ENDED_DISPLAY_MS = 2_000L // show "call ended" briefly before resetting const val MAX_EVENT_AGE_SECONDS = 20L // discard signaling events older than this const val MAX_PROCESSED_EVENT_IDS = 2_000 // cap dedup set to prevent unbounded growth @@ -140,7 +149,8 @@ class CallManager( callType: CallType, ) = stateMutex.withLock { _state.value = CallState.Offering(callId, calleePubKeys, callType) - startTimeout(callId) + cancelAllPeerTimeouts() + calleePubKeys.forEach { schedulePeerTimeout(it, callId) } } /** @@ -201,10 +211,11 @@ class CallManager( val result = factory.createCallOffer(sdpOffer, calleePubKey, callId, callType, signer) stateMutex.withLock { _state.value = CallState.Offering(callId, setOf(calleePubKey), callType) - startTimeout(callId) + cancelAllPeerTimeouts() + schedulePeerTimeout(calleePubKey, callId) } publishEvent(result.wrap) - Log.d("CallManager") { "initiateCall: offer published, timeout started" } + Log.d("CallManager") { "initiateCall: offer published, per-peer timeout started" } } // ---- Incoming call handling ---- @@ -348,7 +359,9 @@ class CallManager( current.callType, pendingPeerPubKeys = pending, ) - cancelTimeout() + // The answered peer no longer needs its invite timer. Peers + // still in `pending` keep theirs (scheduled in beginOffering). + cancelPeerTimeout(answeringPeer) Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallController" } onAnswerReceived?.invoke(event) } @@ -362,6 +375,7 @@ class CallManager( peerPubKeys = current.peerPubKeys + answeringPeer, pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, ) + cancelPeerTimeout(answeringPeer) } answeringPeer !in current.peerPubKeys -> { @@ -398,6 +412,7 @@ class CallManager( peerPubKeys = current.peerPubKeys + answeringPeer, pendingPeerPubKeys = current.pendingPeerPubKeys - answeringPeer, ) + cancelPeerTimeout(answeringPeer) } answeringPeer !in current.peerPubKeys -> { @@ -433,6 +448,7 @@ class CallManager( when (current) { is CallState.Offering -> { if (callId != current.callId) return + cancelPeerTimeout(rejectingPeer) val remaining = current.peerPubKeys - rejectingPeer if (remaining.isEmpty()) { transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED) @@ -444,6 +460,7 @@ class CallManager( is CallState.Connecting -> { if (callId != current.callId) return + cancelPeerTimeout(rejectingPeer) _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) onPeerLeft?.invoke(rejectingPeer) @@ -451,6 +468,7 @@ class CallManager( is CallState.Connected -> { if (callId != current.callId) return + cancelPeerTimeout(rejectingPeer) _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer) onPeerLeft?.invoke(rejectingPeer) @@ -567,6 +585,10 @@ class CallManager( } } + // Start the per-peer invite timer. If the invitee does not answer + // within PEER_INVITE_TIMEOUT_MS, they are dropped from the call. + schedulePeerTimeout(peerPubKey, callId) + val allMembers = existingMembers + peerPubKey + signer.pubKey val result = factory.createCallOffer(sdpOffer, peerPubKey, allMembers, callId, callType, signer) publishEvent(result.wrap) @@ -616,6 +638,7 @@ class CallManager( when (current) { is CallState.Connected -> { if (callId != current.callId) return + cancelPeerTimeout(leavingPeer) val connectedRemaining = current.peerPubKeys - leavingPeer val pendingRemaining = current.pendingPeerPubKeys - leavingPeer if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { @@ -634,6 +657,7 @@ class CallManager( is CallState.Connecting -> { if (callId != current.callId) return + cancelPeerTimeout(leavingPeer) val connectedRemaining = current.peerPubKeys - leavingPeer val pendingRemaining = current.pendingPeerPubKeys - leavingPeer if (connectedRemaining.isEmpty() && pendingRemaining.isEmpty()) { @@ -652,6 +676,7 @@ class CallManager( is CallState.Offering -> { if (callId != current.callId) return + cancelPeerTimeout(leavingPeer) val remaining = current.peerPubKeys - leavingPeer if (remaining.isEmpty()) { transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP) @@ -763,6 +788,7 @@ class CallManager( fun reset() { _state.value = CallState.Idle cancelTimeout() + cancelAllPeerTimeouts() resetJob?.cancel() resetJob = null processedEventIds.clear() @@ -780,6 +806,7 @@ class CallManager( discoveredCalleePeers.clear() _state.value = CallState.Ended(callId, peerPubKeys, reason) cancelTimeout() + cancelAllPeerTimeouts() resetJob?.cancel() resetJob = scope.launch { @@ -791,6 +818,118 @@ class CallManager( } } + // ---- Per-peer invite timeout ---- + + /** + * Starts a 30-second timer for [peerPubKey]. If the peer has not answered + * by the time it fires, [handlePeerTimeout] drops them from the current + * group call and publishes a CallHangup to them so their device stops + * ringing. + * + * Safe to call multiple times for the same peer — any previous timer is + * cancelled first. + */ + private fun schedulePeerTimeout( + peerPubKey: HexKey, + callId: String, + ) { + perPeerTimeoutJobs.remove(peerPubKey)?.cancel() + perPeerTimeoutJobs[peerPubKey] = + scope.launch { + delay(PEER_INVITE_TIMEOUT_MS) + handlePeerTimeout(peerPubKey, callId) + } + } + + /** Cancels the per-peer timer for [peerPubKey], if any. */ + private fun cancelPeerTimeout(peerPubKey: HexKey) { + perPeerTimeoutJobs.remove(peerPubKey)?.cancel() + } + + /** Cancels every per-peer timer. Called on terminal state transitions. */ + private fun cancelAllPeerTimeouts() { + perPeerTimeoutJobs.values.forEach { it.cancel() } + perPeerTimeoutJobs.clear() + } + + /** + * Handles a per-peer timeout firing. Drops the peer from the current + * group call state and publishes a CallHangup to them. + * + * - In [CallState.Offering] the peer is removed from `peerPubKeys`; if no + * peers remain, the whole call ends with [EndReason.TIMEOUT]. + * - In [CallState.Connecting] the peer is removed from `pendingPeerPubKeys`; + * if that leaves nobody connected AND no more pending, the call ends + * with [EndReason.TIMEOUT]. Otherwise the call continues with the + * already-connected peers. + * - In [CallState.Connected] the peer is removed from `pendingPeerPubKeys`; + * at least one other peer is connected by definition, so the call + * always continues. + * + * Fires [onPeerLeft] so the CallController disposes the per-peer + * PeerConnection (and any pending ICE buffers) for the dropped peer. + */ + private suspend fun handlePeerTimeout( + peerPubKey: HexKey, + callId: String, + ) { + var shouldPublishHangup = false + stateMutex.withLock { + perPeerTimeoutJobs.remove(peerPubKey) + when (val current = _state.value) { + is CallState.Offering -> { + if (callId != current.callId) return@withLock + if (peerPubKey !in current.peerPubKeys) return@withLock + Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Offering" } + shouldPublishHangup = true + val remaining = current.peerPubKeys - peerPubKey + if (remaining.isEmpty()) { + transitionToEnded(current.callId, current.peerPubKeys, EndReason.TIMEOUT) + } else { + _state.value = current.copy(peerPubKeys = remaining) + onPeerLeft?.invoke(peerPubKey) + } + } + + is CallState.Connecting -> { + if (callId != current.callId) return@withLock + if (peerPubKey !in current.pendingPeerPubKeys) return@withLock + Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connecting" } + shouldPublishHangup = true + val newPending = current.pendingPeerPubKeys - peerPubKey + if (current.peerPubKeys.isEmpty() && newPending.isEmpty()) { + transitionToEnded( + current.callId, + current.peerPubKeys + current.pendingPeerPubKeys, + EndReason.TIMEOUT, + ) + } else { + _state.value = current.copy(pendingPeerPubKeys = newPending) + onPeerLeft?.invoke(peerPubKey) + } + } + + is CallState.Connected -> { + if (callId != current.callId) return@withLock + if (peerPubKey !in current.pendingPeerPubKeys) return@withLock + Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connected" } + shouldPublishHangup = true + _state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - peerPubKey) + onPeerLeft?.invoke(peerPubKey) + } + + else -> { + return@withLock + } + } + } + + if (shouldPublishHangup) { + val result = factory.createHangup(peerPubKey, callId, signer = signer) + publishEvent(result.wrap) + } + } + private fun startTimeout(callId: String) { cancelTimeout() timeoutJob = 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 d1034a5e2..6c508a39a 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 @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -1291,6 +1292,185 @@ class CallManagerTest { assertEquals(carol, bobForwardedAnswer, "Bob must forward Carol's answer to his CallController") } + // ======================================================================== + // Per-peer 30-second invite timeout + // ======================================================================== + + /** + * P2P call: Alice calls Bob, Bob never answers. After 30 s Alice's + * per-peer timer fires and the call ends with [EndReason.TIMEOUT]. The + * timeout hangup is also published so Bob's device stops ringing. + */ + @Test + fun perPeerTimeoutEndsP2PCallWhenBobNeverAnswers() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + published.clear() + + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100) + + val ended = manager.state.value + assertIs(ended) + assertEquals(EndReason.TIMEOUT, ended.reason) + assertEquals( + 1, + published.size, + "Timeout should publish exactly one hangup to the unresponsive peer", + ) + } + + /** + * Group call: Alice offers to Bob + Carol. Bob answers quickly, Carol + * does not. After 30 s Alice's timer for Carol fires and Carol is + * removed from pending. The call continues with Bob. A hangup is + * published to Carol but the Bob leg is untouched. + */ + @Test + fun perPeerTimeoutDropsSlowCalleeFromGroupCall() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + assertIs(manager.state.value) + + // Bob answers quickly (well before the 30 s timer). + manager.onSignalingEvent(makeGroupAnswer(from = bob, members = setOf(alice, bob, carol))) + val afterBob = manager.state.value + assertIs(afterBob) + assertTrue(bob in afterBob.peerPubKeys) + assertTrue(carol in afterBob.pendingPeerPubKeys) + published.clear() + + // Advance past the 30 s per-peer timeout. + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100) + + // Carol dropped; call continues with Bob. + val state = manager.state.value + assertIs(state) + assertTrue(carol !in state.pendingPeerPubKeys, "Carol should be dropped from pending") + assertTrue(bob in state.peerPubKeys, "Bob leg must be untouched") + + assertEquals( + 1, + published.size, + "Timeout must publish exactly one hangup (addressed to Carol)", + ) + } + + /** + * Bob answering inside the 30 s window cancels his per-peer timer. + * Advancing 60 s afterwards must not fire any phantom timeout. + */ + @Test + fun perPeerTimeoutIsCancelledOnAnswer() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + assertIs(manager.state.value) + + // Bob answers before the timeout fires. + manager.onSignalingEvent(makeAnswer(from = bob, to = alice)) + assertIs(manager.state.value) + manager.onPeerConnected() + assertIs(manager.state.value) + published.clear() + + // Advance way past the 30 s timeout — no timeout must fire. + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS * 2) + + assertIs(manager.state.value) + assertTrue(published.isEmpty(), "No timeout hangup must be published after a successful answer") + } + + /** + * Mid-call invite: Alice is Connected with Bob, then invites Carol. + * Carol never answers. After 30 s Carol is dropped from + * [CallState.Connected.pendingPeerPubKeys] and the call continues with + * Bob. A hangup is published to Carol. + */ + @Test + fun perPeerTimeoutDropsMidCallInviteeWhenNoAnswer() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + // Alice ↔ Bob Connected. + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + manager.onSignalingEvent(makeAnswer(from = bob, to = alice)) + manager.onPeerConnected() + assertIs(manager.state.value) + + // Alice invites Carol. + manager.invitePeer(carol, "invite-sdp") + val afterInvite = manager.state.value + assertIs(afterInvite) + assertTrue(carol in afterInvite.pendingPeerPubKeys) + published.clear() + + // Carol never answers — advance past the 30 s invite timeout. + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100) + + val state = manager.state.value + assertIs(state) + assertTrue(carol !in state.pendingPeerPubKeys, "Carol should be dropped from pending") + assertTrue(bob in state.peerPubKeys, "Bob leg must be untouched") + + assertEquals( + 1, + published.size, + "Invite timeout must publish exactly one hangup addressed to Carol", + ) + } + + /** + * Bob rejecting a P2P offer cancels his per-peer timer so advancing time + * past 30 s afterwards does not publish a duplicate hangup on top of the + * Ended transition. + */ + @Test + fun perPeerTimeoutIsCancelledOnReject() = + runTest { + val (manager, published) = createManager(localPubKey = alice, followedKeys = setOf(bob)) + + manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer) + manager.onSignalingEvent(makeReject(from = bob, to = alice)) + val ended = manager.state.value + assertIs(ended) + assertEquals(EndReason.PEER_REJECTED, ended.reason) + + val publishedAfterReject = published.size + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS * 2) + assertEquals( + publishedAfterReject, + published.size, + "No timeout hangup should be published after the peer rejected", + ) + } + + /** + * When a group offer is made, each callee gets its own timer. If NEITHER + * answers, both timers fire in turn and the whole call ends with + * [EndReason.TIMEOUT] (the second timeout has no peers left, so it tips + * the state over the edge). + */ + @Test + fun perPeerTimeoutEndsCallWhenAllCalleesIgnore() = + runTest { + val (manager, _) = createManager(localPubKey = alice, followedKeys = setOf(bob, carol)) + + manager.beginOffering(callId, setOf(bob, carol), CallType.VOICE) + assertIs(manager.state.value) + + advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100) + + val ended = manager.state.value + assertIs(ended) + assertEquals(EndReason.TIMEOUT, ended.reason) + } + @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 e079ed18d..4674cf237 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 @@ -480,7 +480,8 @@ This NIP does not mandate specific STUN or TURN servers. Clients SHOULD: - The `call-id` tag MUST be a UUID that is unique per call session. All signaling events for the same call share the same `call-id`. - Signaling data is ephemeral and has no long-term value. Using kind `21059` (ephemeral gift wrap) signals to relays that these events are transient and SHOULD NOT be persisted. -- Clients SHOULD implement a ringing timeout (e.g., 60 seconds). If no answer is received, the call transitions to a "timed out" state. +- **Callee ringing timeout**: Clients SHOULD implement a ringing timeout (e.g., 60 seconds) on the callee side. If the user does not accept within the window, the call transitions to a "timed out" state. +- **Caller per-peer invite timeout**: On the caller side in a group call, clients SHOULD track an independent per-peer invite timer (e.g., 30 seconds) for every peer in `pendingPeerPubKeys` (or for every peer in `Offering.peerPubKeys`, which is implicitly pending). When a peer's timer fires without an answer, the caller drops that peer from the current group call, publishes a `CallHangup` (kind 25053) addressed to them (so their device stops ringing), and continues the call with the remaining peers. If the resulting state has zero connected peers and zero pending peers, the call ends with `TIMEOUT`. The same timer is started when inviting a new peer mid-call. This ensures slow or unavailable peers do not stall the rest of the mesh. - After a call ends, the call state SHOULD auto-reset to idle after a brief display period (e.g., 2 seconds) to ensure the client is ready for subsequent calls. ### WebRTC Configuration From 1290e9151c4407ed39a7df39d242a704efb70914 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Apr 2026 01:21:37 +0000 Subject: [PATCH 4/4] feat(settings): add toggle to disable voice and video calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Enable voice and video calls" switch at the top of the Call Settings screen. Default is ON (no behavior change for existing users). When a user turns it OFF: - Call buttons in the chat room top bar are hidden. ChatroomScreen observes account.settings.callsEnabled as a StateFlow and flips onCallClick / onVideoCallClick to null, which RenderRoomTopBar already treats as "no buttons". - Incoming CallOfferEvents are silently dropped in CallManager. A new isCallsEnabled: () -> Boolean hook on the CallManager constructor gates onIncomingCallEvent *before* the followed-user check, so the device never rings and no state transition occurs. Existing in-flight call signaling (answers/hangups/ICE) still flows through so a call that was active when the user flipped the toggle continues to clean up normally. - The rest of the call-related settings (video quality, max bitrate, TURN servers) are hidden on the settings screen since they have no effect while calls are disabled — the screen becomes just the single meaningful toggle. The setting is persisted per account via SharedPreferences (PrefKeys.CALLS_ENABLED = "calls_enabled"), loaded in LocalPreferences.loadFromEncryptedStorageSync, and exposed as a MutableStateFlow on AccountSettings so both the chat top bar and the settings screen react to changes without needing to navigate away and back. AccountViewModel wires isCallsEnabled = { account.settings.callsEnabled.value } into the CallManager constructor so the flag is read lazily on every incoming event. Tests in CallManagerTest: - incomingOfferIgnoredWhenCallsDisabledInSettings — disabled flag drops the offer, no state change, no published events. - disablingCallsAfterStartDoesNotTearDownInProgressCall — an active call keeps working after the toggle flips; only new offers are ignored. - incomingOfferProcessedWhenCallsEnabled — regression guard for the default-enabled path. https://claude.ai/code/session_01XSPDbahLwHs9sdF5XfRvHB --- .../amethyst/LocalPreferences.kt | 4 ++ .../amethyst/model/AccountSettings.kt | 8 +++ .../ui/screen/loggedIn/AccountViewModel.kt | 1 + .../chats/privateDM/ChatroomScreen.kt | 6 +- .../loggedIn/settings/CallSettingsScreen.kt | 51 ++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + .../amethyst/commons/call/CallManager.kt | 18 +++++ .../amethyst/commons/call/CallManagerTest.kt | 69 +++++++++++++++++++ 8 files changed, 158 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8fe883a1e..2ac3fce84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -124,6 +124,7 @@ private object PrefKeys { const val LATEST_GEOHASH_LIST = "latestGeohashList" const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList" const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList" + const val CALLS_ENABLED = "calls_enabled" const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog" const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog" const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later @@ -391,6 +392,7 @@ object LocalPreferences { putBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, settings.hideDeleteRequestDialog) putBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, settings.hideNIP17WarningDialog) putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog) + putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value) // migrating from previous design remove(PrefKeys.USE_PROXY) @@ -494,6 +496,7 @@ object LocalPreferences { val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) + val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true) val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf() val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf() val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null) @@ -651,6 +654,7 @@ object LocalPreferences { viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()), pendingAttestations = MutableStateFlow(pendingAttestations.await()), backupNipA3PaymentTargets = latestPaymentTargets.await(), + callsEnabled = MutableStateFlow(callsEnabled), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index f6b681b61..924b439b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -203,6 +203,7 @@ class AccountSettings( var callTurnServers: List = emptyList(), var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720, var callMaxBitrateBps: Int = 1_500_000, + val callsEnabled: MutableStateFlow = MutableStateFlow(true), ) : EphemeralChatRepository, PublicChatListRepository { val saveable = MutableStateFlow(AccountSettingsUpdater(null)) @@ -942,6 +943,13 @@ class AccountSettings( callMaxBitrateBps = bitrate saveAccountSettings() } + + fun changeCallsEnabled(enabled: Boolean) { + if (callsEnabled.value != enabled) { + callsEnabled.tryEmit(enabled) + saveAccountSettings() + } + } } @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index af27793ee..8cb6ebac8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -199,6 +199,7 @@ class AccountViewModel( account.publishCallSignaling(wrap) } }, + isCallsEnabled = { account.settings.callsEnabled.value }, ) var callController: CallController? = null 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 397099f7c..f320c8341 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 @@ -25,6 +25,8 @@ import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import com.vitorpamplona.amethyst.service.call.CallSessionBridge @@ -49,7 +51,9 @@ fun ChatroomScreen( nav: INav, ) { val context = LocalContext.current - val isCallSupported = roomId.users.size <= 5 + val callsEnabled by accountViewModel.account.settings.callsEnabled + .collectAsState() + val isCallSupported = roomId.users.size <= 5 && callsEnabled val startVoiceCall = rememberCallWithPermission(context) { CallSessionBridge.set(accountViewModel.callManager, accountViewModel.callController, accountViewModel) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt index a8b638f3c..4d8fa3bb5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/CallSettingsScreen.kt @@ -42,8 +42,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -85,6 +87,22 @@ fun CallSettingsScreen( @Composable private fun CallSettingsContent(accountViewModel: AccountViewModel) { val settings = accountViewModel.account.settings + val callsEnabled by settings.callsEnabled.collectAsState() + + EnableCallsSection( + enabled = callsEnabled, + onEnabledChanged = { settings.changeCallsEnabled(it) }, + ) + + if (!callsEnabled) { + // When calls are disabled the remaining settings (video quality, + // TURN servers, etc.) have no effect, so hide them to keep the + // screen focused on the single meaningful toggle. + Spacer(modifier = Modifier.height(16.dp)) + return + } + + HorizontalDivider(thickness = 4.dp, modifier = Modifier.padding(vertical = 8.dp)) SectionHeader(stringRes(R.string.call_settings_video_quality)) VideoResolutionSection( @@ -120,6 +138,39 @@ private fun CallSettingsContent(accountViewModel: AccountViewModel) { Spacer(modifier = Modifier.height(16.dp)) } +@Composable +private fun EnableCallsSection( + enabled: Boolean, + onEnabledChanged: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringRes(R.string.call_settings_enable_calls), + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = stringRes(R.string.call_settings_enable_calls_description), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Switch( + checked = enabled, + onCheckedChange = onEnabledChanged, + ) + } +} + @Composable private fun SectionHeader(title: String) { Text( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b43403e23..4fffe2ae2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -815,6 +815,8 @@ Failed to accept call Failed to create call session Call Settings + Enable voice and video calls + When disabled, call buttons are hidden from chat screens and all incoming calls are silently ignored. Video Quality Max Video Bitrate TURN / STUN Servers 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 106cf40cf..44bbe8d96 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 @@ -49,6 +49,15 @@ class CallManager( private val scope: CoroutineScope, private val isFollowing: (HexKey) -> Boolean, private val publishEvent: (EphemeralGiftWrapEvent) -> Unit, + /** + * Whether the user has enabled calls in Settings. When false, all + * incoming [CallOfferEvent]s are silently ignored so the device never + * rings and no `IncomingCall` state is entered. Signaling for calls that + * are already in progress is still processed so cleanup can complete. + * Defaults to `true` (enabled) so existing callers and tests keep their + * current behavior. + */ + private val isCallsEnabled: () -> Boolean = { true }, ) { private val factory = WebRtcCallFactory() @@ -227,6 +236,15 @@ class CallManager( Log.d("CallManager") { "onIncomingCallEvent: from=${callerPubKey.take(8)}, callId=$callId, type=$callType, sdpOfferLength=${event.sdpOffer().length}" } + // User disabled calls in Settings — silently ignore new incoming + // offers so the device does not ring. Mid-call signaling for calls + // that are already in progress is still processed by the other + // branches in onSignalingEvent so cleanup can complete normally. + if (!isCallsEnabled()) { + Log.d("CallManager") { "onIncomingCallEvent: calls disabled in settings — ignoring" } + return + } + if (!isFollowing(callerPubKey)) { Log.d("CallManager") { "onIncomingCallEvent: caller not followed — ignoring" } return 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 6c508a39a..0c487cc0d 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 @@ -85,6 +85,7 @@ class CallManagerTest { private fun TestScope.createManager( localPubKey: HexKey = bob, followedKeys: Set = setOf(alice, carol), + isCallsEnabled: () -> Boolean = { true }, ): Pair> { val published = mutableListOf() val signer = signers[localPubKey] ?: error("Unknown test identity: $localPubKey") @@ -94,6 +95,7 @@ class CallManagerTest { scope = this, isFollowing = { it in followedKeys }, publishEvent = { published.add(it) }, + isCallsEnabled = isCallsEnabled, ) return manager to published } @@ -1534,4 +1536,71 @@ class CallManagerTest { assertIs(aliceManager.state.value) assertIs(bobManager.state.value) } + + // ======================================================================== + // User has disabled calls in Settings + // ======================================================================== + + /** + * When [CallManager.isCallsEnabled] returns false, an incoming + * [CallOfferEvent] is silently dropped — no state change, no ringing, + * no published reject. + */ + @Test + fun incomingOfferIgnoredWhenCallsDisabledInSettings() = + runTest { + val (manager, published) = createManager(localPubKey = bob, isCallsEnabled = { false }) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + + assertIs(manager.state.value) + assertTrue( + published.isEmpty(), + "Disabled calls must not publish any signaling events in response to an incoming offer", + ) + } + + /** + * Toggling the flag to false after a call is already in progress does + * not affect the in-flight call — CallManager only gates *new* incoming + * offers. Signaling for the active call continues to flow so cleanup + * (hangups, answers, ICE candidates) can complete. + */ + @Test + fun disablingCallsAfterStartDoesNotTearDownInProgressCall() = + runTest { + var enabled = true + val (manager, _) = createManager(localPubKey = bob, isCallsEnabled = { enabled }) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + manager.acceptCall(sdpAnswer) + manager.onPeerConnected() + assertIs(manager.state.value) + + // User flips the toggle off mid-call. + enabled = false + + // The existing call is unaffected — Bob can still receive + // hangup/answer/ICE traffic for the current call. + assertIs(manager.state.value) + + // But a *new* offer for a different call is silently ignored. + val newCall = makeOffer(from = carol, to = bob, callId = callId2) + manager.onSignalingEvent(newCall) + assertIs(manager.state.value) + } + + /** + * Regression: when calls are enabled (the default) the incoming-offer + * path still works exactly as before. + */ + @Test + fun incomingOfferProcessedWhenCallsEnabled() = + runTest { + val (manager, _) = createManager(localPubKey = bob, isCallsEnabled = { true }) + + manager.onSignalingEvent(makeOffer(from = alice, to = bob)) + + assertIs(manager.state.value) + } }