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
@@ -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<String>,
pendingPeerPubKeys: Set<String>,
remoteVideoTracks: Map<String, VideoTrack>,
activePeerVideos: Set<String>,
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,
)
}
}
}
}
@@ -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<String, VideoTrack>() }
val emptyActive = remember { emptySet<String>() }
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))
}
}
@@ -79,6 +79,14 @@ class CallManager(
private var resetJob: Job? = null
private val processedEventIds = LinkedHashSet<String>()
/** 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<HexKey, Job>()
/** 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<HexKey>()
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 =
@@ -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 {
@@ -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