feat(call): per-peer 30s invite timeout and per-peer status in video grid

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
This commit is contained in:
Claude
2026-04-14 23:23:42 +00:00
parent d087e1ac13
commit b9d6cefbeb
5 changed files with 362 additions and 39 deletions
@@ -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<CallState.Offering>(manager.state.value)
published.clear()
advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100)
val ended = manager.state.value
assertIs<CallState.Ended>(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<CallState.Offering>(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<CallState.Connecting>(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<CallState.Connecting>(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<CallState.Offering>(manager.state.value)
// Bob answers before the timeout fires.
manager.onSignalingEvent(makeAnswer(from = bob, to = alice))
assertIs<CallState.Connecting>(manager.state.value)
manager.onPeerConnected()
assertIs<CallState.Connected>(manager.state.value)
published.clear()
// Advance way past the 30 s timeout — no timeout must fire.
advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS * 2)
assertIs<CallState.Connected>(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<CallState.Connected>(manager.state.value)
// Alice invites Carol.
manager.invitePeer(carol, "invite-sdp")
val afterInvite = manager.state.value
assertIs<CallState.Connected>(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<CallState.Connected>(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<CallState.Ended>(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<CallState.Offering>(manager.state.value)
advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100)
val ended = manager.state.value
assertIs<CallState.Ended>(ended)
assertEquals(EndReason.TIMEOUT, ended.reason)
}
@Test
fun interfaceFullP2PCallFlowWithRealSigners() =
runTest {