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:
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+144
-5
@@ -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 =
|
||||
|
||||
+180
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user