Merge branch 'main' of https://github.com/vitorpamplona/amethyst
This commit is contained in:
+88
-8
@@ -96,6 +96,18 @@ class CallManager(
|
|||||||
* whole call ends. */
|
* whole call ends. */
|
||||||
private val perPeerTimeoutJobs = mutableMapOf<HexKey, Job>()
|
private val perPeerTimeoutJobs = mutableMapOf<HexKey, Job>()
|
||||||
|
|
||||||
|
/** Peers we have published a CallOffer to (either as the call initiator
|
||||||
|
* or via a mid-call invitePeer). When their per-peer timer fires we
|
||||||
|
* publish a CallHangup so their device stops ringing.
|
||||||
|
*
|
||||||
|
* Peers we are merely *waiting* on (i.e. members of a group call we
|
||||||
|
* accepted but who haven't yet joined via mesh) are tracked only with
|
||||||
|
* their timer job — NOT in this set. When their timer fires we drop
|
||||||
|
* them silently from local state without sending any further
|
||||||
|
* signaling, because their ringing is the caller's responsibility to
|
||||||
|
* terminate. */
|
||||||
|
private val peersInvitedByUs = mutableSetOf<HexKey>()
|
||||||
|
|
||||||
/** Call IDs for which we have seen a hangup, reject, or answer-elsewhere
|
/** Call IDs for which we have seen a hangup, reject, or answer-elsewhere
|
||||||
* signal. Checked before transitioning to [CallState.IncomingCall] so
|
* signal. Checked before transitioning to [CallState.IncomingCall] so
|
||||||
* that stale offer events replayed by relays after an app restart do not
|
* that stale offer events replayed by relays after an app restart do not
|
||||||
@@ -159,6 +171,7 @@ class CallManager(
|
|||||||
) = stateMutex.withLock {
|
) = stateMutex.withLock {
|
||||||
_state.value = CallState.Offering(callId, calleePubKeys, callType)
|
_state.value = CallState.Offering(callId, calleePubKeys, callType)
|
||||||
cancelAllPeerTimeouts()
|
cancelAllPeerTimeouts()
|
||||||
|
peersInvitedByUs.addAll(calleePubKeys)
|
||||||
calleePubKeys.forEach { schedulePeerTimeout(it, callId) }
|
calleePubKeys.forEach { schedulePeerTimeout(it, callId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +234,7 @@ class CallManager(
|
|||||||
stateMutex.withLock {
|
stateMutex.withLock {
|
||||||
_state.value = CallState.Offering(callId, setOf(calleePubKey), callType)
|
_state.value = CallState.Offering(callId, setOf(calleePubKey), callType)
|
||||||
cancelAllPeerTimeouts()
|
cancelAllPeerTimeouts()
|
||||||
|
peersInvitedByUs.add(calleePubKey)
|
||||||
schedulePeerTimeout(calleePubKey, callId)
|
schedulePeerTimeout(calleePubKey, callId)
|
||||||
}
|
}
|
||||||
publishEvent(result.wrap)
|
publishEvent(result.wrap)
|
||||||
@@ -266,6 +280,35 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
if (callId == currentCallId) {
|
if (callId == currentCallId) {
|
||||||
Log.d("CallManager") { "Mid-call offer from ${callerPubKey.take(8)} for current call — callee-to-callee" }
|
Log.d("CallManager") { "Mid-call offer from ${callerPubKey.take(8)} for current call — callee-to-callee" }
|
||||||
|
// If this peer was in our pending set (we were waiting for
|
||||||
|
// them to join), move them into the connected set and
|
||||||
|
// cancel their watchdog timer — the offer is proof they're
|
||||||
|
// in the call.
|
||||||
|
when (currentState) {
|
||||||
|
is CallState.Connecting -> {
|
||||||
|
if (callerPubKey in currentState.pendingPeerPubKeys) {
|
||||||
|
_state.value =
|
||||||
|
currentState.copy(
|
||||||
|
peerPubKeys = currentState.peerPubKeys + callerPubKey,
|
||||||
|
pendingPeerPubKeys = currentState.pendingPeerPubKeys - callerPubKey,
|
||||||
|
)
|
||||||
|
cancelPeerTimeout(callerPubKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is CallState.Connected -> {
|
||||||
|
if (callerPubKey in currentState.pendingPeerPubKeys) {
|
||||||
|
_state.value =
|
||||||
|
currentState.copy(
|
||||||
|
peerPubKeys = currentState.peerPubKeys + callerPubKey,
|
||||||
|
pendingPeerPubKeys = currentState.pendingPeerPubKeys - callerPubKey,
|
||||||
|
)
|
||||||
|
cancelPeerTimeout(callerPubKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
onMidCallOfferReceived?.invoke(callerPubKey, event.sdpOffer())
|
onMidCallOfferReceived?.invoke(callerPubKey, event.sdpOffer())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -303,11 +346,39 @@ class CallManager(
|
|||||||
}
|
}
|
||||||
current = s
|
current = s
|
||||||
|
|
||||||
Log.d("CallManager") { "acceptCall: callId=${current.callId}, transitioning to Connecting, sdpAnswerLength=${sdpAnswer.length}" }
|
|
||||||
_state.value = CallState.Connecting(current.callId, current.peerPubKeys() - signer.pubKey, current.callType)
|
|
||||||
cancelTimeout()
|
|
||||||
discovered = discoveredCalleePeers.toSet()
|
discovered = discoveredCalleePeers.toSet()
|
||||||
discoveredCalleePeers.clear()
|
discoveredCalleePeers.clear()
|
||||||
|
|
||||||
|
// Split group members into "known to be in the call" vs "still
|
||||||
|
// waiting on". The caller is definitely in the call (they sent
|
||||||
|
// us the offer). Peers whose answer we observed while ringing
|
||||||
|
// are also confirmed. Everyone else is placed in
|
||||||
|
// pendingPeerPubKeys with a per-peer watchdog timer so that
|
||||||
|
// unresponsive group members are dropped from our local UI
|
||||||
|
// after the same 30-second budget the caller uses — otherwise
|
||||||
|
// we would wait for them forever (the caller's timeout hangup
|
||||||
|
// is addressed only to the unresponsive peer and never reaches
|
||||||
|
// the rest of us).
|
||||||
|
val knownPeers = (discovered + current.callerPubKey) - signer.pubKey
|
||||||
|
val pendingOnJoin = (current.peerPubKeys() - signer.pubKey) - knownPeers
|
||||||
|
|
||||||
|
Log.d("CallManager") {
|
||||||
|
"acceptCall: callId=${current.callId}, transitioning to Connecting, " +
|
||||||
|
"known=${knownPeers.size}, pending=${pendingOnJoin.size}, " +
|
||||||
|
"sdpAnswerLength=${sdpAnswer.length}"
|
||||||
|
}
|
||||||
|
_state.value =
|
||||||
|
CallState.Connecting(
|
||||||
|
callId = current.callId,
|
||||||
|
peerPubKeys = knownPeers,
|
||||||
|
callType = current.callType,
|
||||||
|
pendingPeerPubKeys = pendingOnJoin,
|
||||||
|
)
|
||||||
|
cancelTimeout()
|
||||||
|
// Local watchdog timers only — we are not the inviter for these
|
||||||
|
// peers, so [handlePeerTimeout] will silently drop them without
|
||||||
|
// publishing any hangup (see `peersInvitedByUs`).
|
||||||
|
pendingOnJoin.forEach { schedulePeerTimeout(it, current.callId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
val allMembers = current.groupMembers + signer.pubKey
|
val allMembers = current.groupMembers + signer.pubKey
|
||||||
@@ -605,6 +676,8 @@ class CallManager(
|
|||||||
|
|
||||||
// Start the per-peer invite timer. If the invitee does not answer
|
// Start the per-peer invite timer. If the invitee does not answer
|
||||||
// within PEER_INVITE_TIMEOUT_MS, they are dropped from the call.
|
// within PEER_INVITE_TIMEOUT_MS, they are dropped from the call.
|
||||||
|
// We sent the offer, so a timeout must also publish a hangup.
|
||||||
|
peersInvitedByUs.add(peerPubKey)
|
||||||
schedulePeerTimeout(peerPubKey, callId)
|
schedulePeerTimeout(peerPubKey, callId)
|
||||||
|
|
||||||
val allMembers = existingMembers + peerPubKey + signer.pubKey
|
val allMembers = existingMembers + peerPubKey + signer.pubKey
|
||||||
@@ -862,12 +935,14 @@ class CallManager(
|
|||||||
/** Cancels the per-peer timer for [peerPubKey], if any. */
|
/** Cancels the per-peer timer for [peerPubKey], if any. */
|
||||||
private fun cancelPeerTimeout(peerPubKey: HexKey) {
|
private fun cancelPeerTimeout(peerPubKey: HexKey) {
|
||||||
perPeerTimeoutJobs.remove(peerPubKey)?.cancel()
|
perPeerTimeoutJobs.remove(peerPubKey)?.cancel()
|
||||||
|
peersInvitedByUs.remove(peerPubKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancels every per-peer timer. Called on terminal state transitions. */
|
/** Cancels every per-peer timer. Called on terminal state transitions. */
|
||||||
private fun cancelAllPeerTimeouts() {
|
private fun cancelAllPeerTimeouts() {
|
||||||
perPeerTimeoutJobs.values.forEach { it.cancel() }
|
perPeerTimeoutJobs.values.forEach { it.cancel() }
|
||||||
perPeerTimeoutJobs.clear()
|
perPeerTimeoutJobs.clear()
|
||||||
|
peersInvitedByUs.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -894,12 +969,17 @@ class CallManager(
|
|||||||
var shouldPublishHangup = false
|
var shouldPublishHangup = false
|
||||||
stateMutex.withLock {
|
stateMutex.withLock {
|
||||||
perPeerTimeoutJobs.remove(peerPubKey)
|
perPeerTimeoutJobs.remove(peerPubKey)
|
||||||
|
// Only publish a hangup if we were the peer's inviter. Pure
|
||||||
|
// "watchdog" timers started by a callee for group members who
|
||||||
|
// never joined must NOT publish anything — terminating the
|
||||||
|
// invitee's ringing is the caller's responsibility.
|
||||||
|
val wasInvitedByUs = peersInvitedByUs.remove(peerPubKey)
|
||||||
when (val current = _state.value) {
|
when (val current = _state.value) {
|
||||||
is CallState.Offering -> {
|
is CallState.Offering -> {
|
||||||
if (callId != current.callId) return@withLock
|
if (callId != current.callId) return@withLock
|
||||||
if (peerPubKey !in current.peerPubKeys) return@withLock
|
if (peerPubKey !in current.peerPubKeys) return@withLock
|
||||||
Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Offering" }
|
Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Offering" }
|
||||||
shouldPublishHangup = true
|
shouldPublishHangup = wasInvitedByUs
|
||||||
val remaining = current.peerPubKeys - peerPubKey
|
val remaining = current.peerPubKeys - peerPubKey
|
||||||
if (remaining.isEmpty()) {
|
if (remaining.isEmpty()) {
|
||||||
transitionToEnded(current.callId, current.peerPubKeys, EndReason.TIMEOUT)
|
transitionToEnded(current.callId, current.peerPubKeys, EndReason.TIMEOUT)
|
||||||
@@ -912,8 +992,8 @@ class CallManager(
|
|||||||
is CallState.Connecting -> {
|
is CallState.Connecting -> {
|
||||||
if (callId != current.callId) return@withLock
|
if (callId != current.callId) return@withLock
|
||||||
if (peerPubKey !in current.pendingPeerPubKeys) return@withLock
|
if (peerPubKey !in current.pendingPeerPubKeys) return@withLock
|
||||||
Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connecting" }
|
Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connecting (invitedByUs=$wasInvitedByUs)" }
|
||||||
shouldPublishHangup = true
|
shouldPublishHangup = wasInvitedByUs
|
||||||
val newPending = current.pendingPeerPubKeys - peerPubKey
|
val newPending = current.pendingPeerPubKeys - peerPubKey
|
||||||
if (current.peerPubKeys.isEmpty() && newPending.isEmpty()) {
|
if (current.peerPubKeys.isEmpty() && newPending.isEmpty()) {
|
||||||
transitionToEnded(
|
transitionToEnded(
|
||||||
@@ -930,8 +1010,8 @@ class CallManager(
|
|||||||
is CallState.Connected -> {
|
is CallState.Connected -> {
|
||||||
if (callId != current.callId) return@withLock
|
if (callId != current.callId) return@withLock
|
||||||
if (peerPubKey !in current.pendingPeerPubKeys) return@withLock
|
if (peerPubKey !in current.pendingPeerPubKeys) return@withLock
|
||||||
Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connected" }
|
Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connected (invitedByUs=$wasInvitedByUs)" }
|
||||||
shouldPublishHangup = true
|
shouldPublishHangup = wasInvitedByUs
|
||||||
_state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - peerPubKey)
|
_state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - peerPubKey)
|
||||||
onPeerLeft?.invoke(peerPubKey)
|
onPeerLeft?.invoke(peerPubKey)
|
||||||
}
|
}
|
||||||
|
|||||||
+132
-7
@@ -1162,8 +1162,9 @@ class CallManagerTest {
|
|||||||
* Regression: in an initial group call, the callees observing each other's
|
* Regression: in an initial group call, the callees observing each other's
|
||||||
* answers MUST NOT trip the mid-call expansion branch. The answering peer
|
* answers MUST NOT trip the mid-call expansion branch. The answering peer
|
||||||
* was already part of the group membership set by [acceptCall] (via the
|
* was already part of the group membership set by [acceptCall] (via the
|
||||||
* IncomingCall.groupMembers → Connecting.peerPubKeys transition), so no
|
* IncomingCall.groupMembers → Connecting.peerPubKeys/pendingPeerPubKeys
|
||||||
* additional insertion should occur.
|
* transition), so the combined size must stay the same — the peer only
|
||||||
|
* moves out of pending into connected.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun initialCallAnswerFromKnownPeerDoesNotExpandMembership() =
|
fun initialCallAnswerFromKnownPeerDoesNotExpandMembership() =
|
||||||
@@ -1174,14 +1175,18 @@ class CallManagerTest {
|
|||||||
manager.onSignalingEvent(makeGroupOffer(from = alice, members = setOf(bob, carol)))
|
manager.onSignalingEvent(makeGroupOffer(from = alice, members = setOf(bob, carol)))
|
||||||
assertIs<CallState.IncomingCall>(manager.state.value)
|
assertIs<CallState.IncomingCall>(manager.state.value)
|
||||||
|
|
||||||
// Bob accepts. State becomes Connecting(peerPubKeys={alice, carol}).
|
// Bob accepts. State becomes
|
||||||
|
// Connecting(peerPubKeys={alice}, pendingPeerPubKeys={carol}).
|
||||||
manager.acceptCall(sdpAnswer)
|
manager.acceptCall(sdpAnswer)
|
||||||
val connecting = manager.state.value
|
val connecting = manager.state.value
|
||||||
assertIs<CallState.Connecting>(connecting)
|
assertIs<CallState.Connecting>(connecting)
|
||||||
assertTrue(alice in connecting.peerPubKeys)
|
assertTrue(alice in connecting.peerPubKeys)
|
||||||
assertTrue(carol in connecting.peerPubKeys)
|
assertTrue(
|
||||||
|
carol in connecting.pendingPeerPubKeys,
|
||||||
|
"Carol should start in pending until Bob observes her joining",
|
||||||
|
)
|
||||||
|
|
||||||
val sizeBefore = connecting.peerPubKeys.size
|
val totalBefore = connecting.peerPubKeys.size + connecting.pendingPeerPubKeys.size
|
||||||
|
|
||||||
// Carol — who is already in Bob's tracked membership — answers.
|
// Carol — who is already in Bob's tracked membership — answers.
|
||||||
// This is the normal initial-call mesh observation path.
|
// This is the normal initial-call mesh observation path.
|
||||||
@@ -1189,7 +1194,10 @@ class CallManagerTest {
|
|||||||
|
|
||||||
val after = manager.state.value
|
val after = manager.state.value
|
||||||
assertIs<CallState.Connecting>(after)
|
assertIs<CallState.Connecting>(after)
|
||||||
assertEquals(sizeBefore, after.peerPubKeys.size, "Known peer's answer must not grow peerPubKeys")
|
val totalAfter = after.peerPubKeys.size + after.pendingPeerPubKeys.size
|
||||||
|
assertEquals(totalBefore, totalAfter, "Known peer's answer must not grow the tracked membership")
|
||||||
|
assertTrue(carol in after.peerPubKeys, "Carol should move into peerPubKeys on answer")
|
||||||
|
assertTrue(carol !in after.pendingPeerPubKeys, "Carol should leave pendingPeerPubKeys on answer")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1261,11 +1269,17 @@ class CallManagerTest {
|
|||||||
|
|
||||||
// Step 4: Carol accepts. Her Connecting state must include Bob
|
// Step 4: Carol accepts. Her Connecting state must include Bob
|
||||||
// (so later mid-call offers from Bob are handled correctly).
|
// (so later mid-call offers from Bob are handled correctly).
|
||||||
|
// Alice (the caller) is placed directly into peerPubKeys; Bob
|
||||||
|
// is placed into pendingPeerPubKeys with a local watchdog
|
||||||
|
// timer until we observe him in the call.
|
||||||
carolManager.acceptCall("carol-answer-sdp")
|
carolManager.acceptCall("carol-answer-sdp")
|
||||||
val carolConnecting = carolManager.state.value
|
val carolConnecting = carolManager.state.value
|
||||||
assertIs<CallState.Connecting>(carolConnecting)
|
assertIs<CallState.Connecting>(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")
|
assertTrue(alice in carolConnecting.peerPubKeys, "Carol's Connecting state must include Alice as a peer")
|
||||||
|
assertTrue(
|
||||||
|
bob in carolConnecting.pendingPeerPubKeys,
|
||||||
|
"Carol's Connecting state must track Bob as a pending peer until he joins",
|
||||||
|
)
|
||||||
|
|
||||||
// Step 5: Alice receives Carol's answer broadcast. Carol moves
|
// Step 5: Alice receives Carol's answer broadcast. Carol moves
|
||||||
// out of pending into peerPubKeys.
|
// out of pending into peerPubKeys.
|
||||||
@@ -1452,6 +1466,117 @@ class CallManagerTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callee-side group call: Bob accepts a group offer from Alice that
|
||||||
|
* also included Carol. Carol never joins. After 30 s Bob's local
|
||||||
|
* watchdog timer drops Carol from his Connecting state WITHOUT
|
||||||
|
* publishing any signaling (the caller is responsible for terminating
|
||||||
|
* Carol's ringing — not Bob).
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun perPeerTimeoutDropsUnresponsiveGroupMemberOnCalleeSide() =
|
||||||
|
runTest {
|
||||||
|
val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol))
|
||||||
|
|
||||||
|
// Alice calls {bob, carol} as a group. Bob receives and accepts.
|
||||||
|
manager.onSignalingEvent(makeGroupOffer(from = alice, members = setOf(bob, carol)))
|
||||||
|
assertIs<CallState.IncomingCall>(manager.state.value)
|
||||||
|
manager.acceptCall(sdpAnswer)
|
||||||
|
|
||||||
|
// Bob's Connecting state: Alice (caller) is connected, Carol
|
||||||
|
// is pending with a watchdog timer.
|
||||||
|
val connecting = manager.state.value
|
||||||
|
assertIs<CallState.Connecting>(connecting)
|
||||||
|
assertTrue(alice in connecting.peerPubKeys, "Caller must be in peerPubKeys")
|
||||||
|
assertTrue(carol in connecting.pendingPeerPubKeys, "Unseen peer must be pending")
|
||||||
|
published.clear()
|
||||||
|
|
||||||
|
// Advance past the 30 s per-peer timeout. Carol never sent an
|
||||||
|
// answer or a mesh offer.
|
||||||
|
advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100)
|
||||||
|
|
||||||
|
val state = manager.state.value
|
||||||
|
assertIs<CallState.Connecting>(state)
|
||||||
|
assertTrue(carol !in state.pendingPeerPubKeys, "Carol must be dropped from pending")
|
||||||
|
assertTrue(carol !in state.peerPubKeys, "Carol must not be silently promoted")
|
||||||
|
assertTrue(alice in state.peerPubKeys, "Alice leg must be untouched")
|
||||||
|
|
||||||
|
// The callee MUST NOT publish a hangup — Carol is still
|
||||||
|
// connected to Alice from the caller's perspective, and
|
||||||
|
// terminating her ringing is Alice's responsibility.
|
||||||
|
assertTrue(
|
||||||
|
published.isEmpty(),
|
||||||
|
"Callee watchdog timeout must not publish any signaling",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callee-side group call: Bob accepts a group offer that included
|
||||||
|
* Carol. Carol publishes a mesh offer to Bob mid-call. Bob must move
|
||||||
|
* Carol out of pending into peerPubKeys and cancel her watchdog timer
|
||||||
|
* — the offer is proof she's in the call.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun midCallOfferFromPendingPeerMovesThemIntoConnected() =
|
||||||
|
runTest {
|
||||||
|
val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol))
|
||||||
|
|
||||||
|
manager.onSignalingEvent(makeGroupOffer(from = alice, members = setOf(bob, carol)))
|
||||||
|
manager.acceptCall(sdpAnswer)
|
||||||
|
val connecting = manager.state.value
|
||||||
|
assertIs<CallState.Connecting>(connecting)
|
||||||
|
assertTrue(carol in connecting.pendingPeerPubKeys)
|
||||||
|
|
||||||
|
// Carol publishes a mesh offer to Bob (same call-id).
|
||||||
|
var midCallOfferPeer: HexKey? = null
|
||||||
|
manager.onMidCallOfferReceived = { peer, _ -> midCallOfferPeer = peer }
|
||||||
|
manager.onSignalingEvent(makeGroupOffer(from = carol, members = setOf(alice, bob, carol)))
|
||||||
|
|
||||||
|
val after = manager.state.value
|
||||||
|
assertIs<CallState.Connecting>(after)
|
||||||
|
assertTrue(carol in after.peerPubKeys, "Carol should move into peerPubKeys")
|
||||||
|
assertTrue(carol !in after.pendingPeerPubKeys, "Carol should leave pending")
|
||||||
|
assertEquals(carol, midCallOfferPeer, "CallController must still receive the mid-call offer")
|
||||||
|
|
||||||
|
// And her watchdog should be cancelled — advancing past the
|
||||||
|
// 30 s budget must not further alter state.
|
||||||
|
advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS + 100)
|
||||||
|
val later = manager.state.value
|
||||||
|
assertIs<CallState.Connecting>(later)
|
||||||
|
assertTrue(carol in later.peerPubKeys, "Carol must remain connected after timeout budget")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: the callee-side watchdog must be cancelled when a
|
||||||
|
* pending peer's answer arrives, so advancing past 30 s afterwards
|
||||||
|
* does not cause a phantom state change or publish.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun calleeWatchdogIsCancelledOnAnswerFromPendingPeer() =
|
||||||
|
runTest {
|
||||||
|
val (manager, published) = createManager(localPubKey = bob, followedKeys = setOf(alice, carol))
|
||||||
|
|
||||||
|
manager.onSignalingEvent(makeGroupOffer(from = alice, members = setOf(bob, carol)))
|
||||||
|
manager.acceptCall(sdpAnswer)
|
||||||
|
assertTrue(carol in (manager.state.value as CallState.Connecting).pendingPeerPubKeys)
|
||||||
|
|
||||||
|
// Carol's answer reaches Bob well before her watchdog fires.
|
||||||
|
manager.onSignalingEvent(makeGroupAnswer(from = carol, members = setOf(alice, bob, carol)))
|
||||||
|
val afterAnswer = manager.state.value
|
||||||
|
assertIs<CallState.Connecting>(afterAnswer)
|
||||||
|
assertTrue(carol in afterAnswer.peerPubKeys)
|
||||||
|
assertTrue(carol !in afterAnswer.pendingPeerPubKeys)
|
||||||
|
published.clear()
|
||||||
|
|
||||||
|
// Advance past the 30 s watchdog — nothing should fire.
|
||||||
|
advanceTimeBy(CallManager.PEER_INVITE_TIMEOUT_MS * 2)
|
||||||
|
assertIs<CallState.Connecting>(manager.state.value)
|
||||||
|
assertTrue(
|
||||||
|
published.isEmpty(),
|
||||||
|
"Cancelled watchdog must not publish a hangup after the peer answered",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When a group offer is made, each callee gets its own timer. If NEITHER
|
* 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
|
* answers, both timers fire in turn and the whole call ends with
|
||||||
|
|||||||
Reference in New Issue
Block a user