refactor: replace CallManager mutable callbacks with SharedFlow

Convert all 5 mutable `var` callback fields on CallManager
(onAnswerReceived, onIceCandidateReceived, onNewPeerInGroupCall,
onMidCallOfferReceived, onPeerLeft) into a single
`sessionEvents: SharedFlow<CallSessionEvent>`.

Benefits:
- Eliminates the stale-callback window between Activity destroy and
  recreate. No more nulling callbacks in onDestroy — the `closed`
  flag on CallSession is sufficient.
- No mutable state shared between Activity and background code.
- CallActivity no longer wires or tears down callbacks — CallSession
  subscribes to the flow in its init block.
- Type-safe sealed interface (CallSessionEvent) replaces 5 separate
  lambda types.

The renegotiationEvents SharedFlow remains separate because
renegotiation has its own glare-resolution logic in CallSession.

Updated all 11 test sites in CallManagerTest to collect from
sessionEvents instead of setting callback vars.

https://claude.ai/code/session_019yNnDjGKmJb19gadmojq54
This commit is contained in:
Claude
2026-04-16 14:48:15 +00:00
parent 980515c0cc
commit ac6d8eb417
5 changed files with 171 additions and 98 deletions
@@ -126,12 +126,9 @@ class CallActivity : AppCompatActivity() {
)
session = callSession
// Wire CallManager callbacks to this session's handlers.
callManager.onAnswerReceived = { event -> callSession.onCallAnswerReceived(event.pubKey, event.sdpAnswer()) }
callManager.onIceCandidateReceived = { event -> callSession.onIceCandidateReceived(event) }
callManager.onNewPeerInGroupCall = { peerPubKey -> callSession.onNewPeerInGroupCall(peerPubKey) }
callManager.onMidCallOfferReceived = { peerPubKey, sdpOffer -> callSession.onMidCallOfferReceived(peerPubKey, sdpOffer) }
callManager.onPeerLeft = { peerPubKey -> callSession.disposePeerSession(peerPubKey) }
// No callback wiring needed — CallSession collects from
// callManager.sessionEvents and renegotiationEvents SharedFlows
// in its init block.
registerPipReceiver()
observeVideoStateForPip(callSession)
@@ -240,15 +237,8 @@ class CallActivity : AppCompatActivity() {
session?.close()
session = null
// Null out callbacks so signaling events arriving after this
// Activity is gone don't route to a dead CallSession.
if (manager != null) {
manager.onAnswerReceived = null
manager.onIceCandidateReceived = null
manager.onNewPeerInGroupCall = null
manager.onMidCallOfferReceived = null
manager.onPeerLeft = null
}
// No callback nulling needed — CallSession collects from
// SharedFlows; the `closed` flag prevents processing after close().
// Publish reject/hangup so the remote side stops ringing.
// Use goAsync-style: the hangup is best-effort. If the process
@@ -141,9 +141,28 @@ class CallSession(
// ---- Initialization ----
init {
// Subscribe to renegotiation events via SharedFlow instead of a
// mutable callback, so we never miss an event across Activity
// restarts.
// Collect all session-level events (answers, ICE candidates,
// peer joins/leaves, mid-call offers) from the single SharedFlow.
scope.launch {
callManager.sessionEvents.collect { event ->
if (closed) return@collect
when (event) {
is com.vitorpamplona.amethyst.commons.call.CallSessionEvent.AnswerReceived ->
onCallAnswerReceived(event.event.pubKey, event.event.sdpAnswer())
is com.vitorpamplona.amethyst.commons.call.CallSessionEvent.IceCandidateReceived ->
onIceCandidateReceived(event.event)
is com.vitorpamplona.amethyst.commons.call.CallSessionEvent.NewPeerInGroupCall ->
onNewPeerInGroupCall(event.peerPubKey)
is com.vitorpamplona.amethyst.commons.call.CallSessionEvent.MidCallOfferReceived ->
onMidCallOfferReceived(event.peerPubKey, event.sdpOffer)
is com.vitorpamplona.amethyst.commons.call.CallSessionEvent.PeerLeft ->
disposePeerSession(event.peerPubKey)
}
}
}
// Renegotiation events have their own flow because they go
// through dedicated glare-resolution logic.
scope.launch {
callManager.renegotiationEvents.collect { event ->
if (!closed) onRenegotiationOfferReceived(event)
@@ -76,29 +76,30 @@ class CallManager(
private val _state = MutableStateFlow<CallState>(CallState.Idle)
val state: StateFlow<CallState> = _state.asStateFlow()
/** Called for every answer that should be applied to a PeerConnection (includes peer pubkey). */
var onAnswerReceived: ((CallAnswerEvent) -> Unit)? = null
var onIceCandidateReceived: ((CallIceCandidateEvent) -> Unit)? = null
/**
* All events that the Activity-scoped `CallSession` must handle are
* emitted through this single flow. Using a SharedFlow instead of
* mutable `var` callbacks means:
* - No stale references to a dead CallSession between Activity
* destroy and recreate.
* - The collector can (re)subscribe via `repeatOnLifecycle` without
* missing buffered events.
* - No null-check on every emit site.
*
* Buffer size 16 covers bursts of ICE candidates + answers arriving
* in quick succession during call setup.
*/
private val _sessionEvents = MutableSharedFlow<CallSessionEvent>(extraBufferCapacity = 16)
val sessionEvents: SharedFlow<CallSessionEvent> = _sessionEvents.asSharedFlow()
/**
* Renegotiation offers from remote peers are emitted as flow events so the
* Activity-scoped `CallSession` can (re)subscribe via `repeatOnLifecycle`
* without losing messages across Activity restarts. Buffered so a short
* gap between Activity teardown and re-collection cannot drop a
* renegotiation.
* Renegotiation offers from remote peers. Separate from [sessionEvents]
* because renegotiation has its own glare-resolution logic in
* CallSession and benefits from a dedicated collector.
*/
private val _renegotiationEvents = MutableSharedFlow<CallRenegotiateEvent>(extraBufferCapacity = 8)
val renegotiationEvents: SharedFlow<CallRenegotiateEvent> = _renegotiationEvents.asSharedFlow()
/** Called when a new peer joins the group call (callee-to-callee mesh setup). */
var onNewPeerInGroupCall: ((peerPubKey: HexKey) -> Unit)? = null
/** Called when a mid-call offer is received from another callee in a group call. */
var onMidCallOfferReceived: ((peerPubKey: HexKey, sdpOffer: String) -> Unit)? = null
/** Called when a peer leaves the call (hangup) but the call continues with remaining peers. */
var onPeerLeft: ((peerPubKey: HexKey) -> Unit)? = null
private var timeoutJob: Job? = null
private var resetJob: Job? = null
private val processedEventIds = LinkedHashSet<String>()
@@ -349,7 +350,7 @@ class CallManager(
else -> {}
}
onMidCallOfferReceived?.invoke(callerPubKey, event.sdpOffer())
_sessionEvents.tryEmit(CallSessionEvent.MidCallOfferReceived(callerPubKey, event.sdpOffer()))
return
}
}
@@ -450,7 +451,7 @@ class CallManager(
if (discovered.isNotEmpty()) {
Log.d("CallManager") { "acceptCall: triggering mesh setup with ${discovered.size} discovered peers: ${discovered.map { it.take(8) }}" }
for (peer in discovered) {
onNewPeerInGroupCall?.invoke(peer)
_sessionEvents.tryEmit(CallSessionEvent.NewPeerInGroupCall(peer))
}
}
}
@@ -517,7 +518,7 @@ class CallManager(
disarmRingingWatchdog()
armConnectingWatchdog(current.callId)
Log.d("CallManager") { "onCallAnswered: Offering -> Connecting, forwarding answer to CallSession" }
onAnswerReceived?.invoke(event)
_sessionEvents.tryEmit(CallSessionEvent.AnswerReceived(event))
}
is CallState.Connecting -> {
@@ -546,7 +547,7 @@ class CallManager(
// Forward to CallController — it routes to the correct PeerSession
// and internally triggers callee-to-callee mesh setup if needed.
Log.d("CallManager") { "onCallAnswered: additional peer ${answeringPeer.take(8)} in Connecting, forwarding to CallController" }
onAnswerReceived?.invoke(event)
_sessionEvents.tryEmit(CallSessionEvent.AnswerReceived(event))
}
is CallState.IncomingCall -> {
@@ -585,7 +586,7 @@ class CallManager(
// Forward to CallController — it routes to the correct PeerSession
// and internally triggers callee-to-callee mesh setup if needed.
Log.d("CallManager") { "onCallAnswered: peer ${answeringPeer.take(8)} answer in Connected, forwarding to CallController" }
onAnswerReceived?.invoke(event)
_sessionEvents.tryEmit(CallSessionEvent.AnswerReceived(event))
}
else -> {
@@ -603,7 +604,7 @@ class CallManager(
// still ringing (IncomingCall). In every other state it is our own
// echo from a relay after a sibling device rejected a call that we
// have already accepted and are now actively in — treating it as a
// peer rejection would drop ourselves from the call (onPeerLeft
// peer rejection would drop ourselves from the call (PeerLeft
// would dispose our own PeerConnection, killing the live audio on
// the device that picked up). Just drop the echo.
if (rejectingPeer == signer.pubKey) {
@@ -625,7 +626,7 @@ class CallManager(
transitionToEnded(current.callId, current.peerPubKeys, EndReason.PEER_REJECTED)
} else {
_state.value = current.copy(peerPubKeys = remaining)
onPeerLeft?.invoke(rejectingPeer)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(rejectingPeer))
}
}
@@ -634,7 +635,7 @@ class CallManager(
cancelPeerTimeout(rejectingPeer)
_state.value =
current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer)
onPeerLeft?.invoke(rejectingPeer)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(rejectingPeer))
}
is CallState.Connected -> {
@@ -642,7 +643,7 @@ class CallManager(
cancelPeerTimeout(rejectingPeer)
_state.value =
current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - rejectingPeer)
onPeerLeft?.invoke(rejectingPeer)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(rejectingPeer))
}
is CallState.IncomingCall -> {
@@ -669,7 +670,7 @@ class CallManager(
}
private fun onIceCandidate(event: CallIceCandidateEvent) {
onIceCandidateReceived?.invoke(event)
_sessionEvents.tryEmit(CallSessionEvent.IceCandidateReceived(event))
}
private fun onRenegotiate(event: CallRenegotiateEvent) {
@@ -831,7 +832,7 @@ class CallManager(
peerPubKeys = connectedRemaining,
pendingPeerPubKeys = pendingRemaining,
)
onPeerLeft?.invoke(leavingPeer)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(leavingPeer))
}
}
@@ -856,7 +857,7 @@ class CallManager(
peerPubKeys = connectedRemaining,
pendingPeerPubKeys = pendingRemaining,
)
onPeerLeft?.invoke(leavingPeer)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(leavingPeer))
}
}
@@ -868,7 +869,7 @@ class CallManager(
transitionToEnded(callId, current.peerPubKeys, EndReason.PEER_HANGUP)
} else {
_state.value = current.copy(peerPubKeys = remaining)
onPeerLeft?.invoke(leavingPeer)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(leavingPeer))
}
}
@@ -1160,7 +1161,7 @@ class CallManager(
* at least one other peer is connected by definition, so the call
* always continues.
*
* Fires [onPeerLeft] so the CallController disposes the per-peer
* Emits [CallSessionEvent.PeerLeft] so the CallSession disposes the per-peer
* PeerConnection (and any pending ICE buffers) for the dropped peer.
*/
private suspend fun handlePeerTimeout(
@@ -1186,7 +1187,7 @@ class CallManager(
transitionToEnded(current.callId, current.peerPubKeys, EndReason.TIMEOUT)
} else {
_state.value = current.copy(peerPubKeys = remaining)
onPeerLeft?.invoke(peerPubKey)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(peerPubKey))
}
}
@@ -1204,7 +1205,7 @@ class CallManager(
)
} else {
_state.value = current.copy(pendingPeerPubKeys = newPending)
onPeerLeft?.invoke(peerPubKey)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(peerPubKey))
}
}
@@ -1214,7 +1215,7 @@ class CallManager(
Log.d("CallManager") { "Per-peer timeout: dropping ${peerPubKey.take(8)} from Connected (invitedByUs=$wasInvitedByUs)" }
shouldPublishHangup = wasInvitedByUs
_state.value = current.copy(pendingPeerPubKeys = current.pendingPeerPubKeys - peerPubKey)
onPeerLeft?.invoke(peerPubKey)
_sessionEvents.tryEmit(CallSessionEvent.PeerLeft(peerPubKey))
}
else -> {
@@ -0,0 +1,47 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.call
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallAnswerEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallIceCandidateEvent
/**
* Events emitted by [CallManager] that the Activity-scoped `CallSession`
* must handle. Delivered via `SharedFlow` so the collector can
* (re)subscribe across Activity restarts without missing buffered events.
*/
sealed interface CallSessionEvent {
/** A peer answered our offer (or we received an answer in a group call). */
data class AnswerReceived(val event: CallAnswerEvent) : CallSessionEvent
/** An ICE candidate arrived from a peer. */
data class IceCandidateReceived(val event: CallIceCandidateEvent) : CallSessionEvent
/** A new peer joined the group call and needs callee-to-callee mesh setup. */
data class NewPeerInGroupCall(val peerPubKey: HexKey) : CallSessionEvent
/** A mid-call offer arrived from another callee in a group call. */
data class MidCallOfferReceived(val peerPubKey: HexKey, val sdpOffer: String) : CallSessionEvent
/** A peer left the call (hangup/reject/timeout) but the call continues. */
data class PeerLeft(val peerPubKey: HexKey) : CallSessionEvent
}
@@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nipACWebRtcCalls.events.CallRenegotiateEvent
import com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
@@ -100,6 +102,16 @@ class CallManagerTest {
return manager to published
}
/**
* Starts collecting [CallManager.sessionEvents] into a list.
* Returns (events, job) — cancel the job when done.
*/
private fun TestScope.collectSessionEvents(manager: CallManager): Pair<MutableList<CallSessionEvent>, Job> {
val events = mutableListOf<CallSessionEvent>()
val job = launch { manager.sessionEvents.collect { events.add(it) } }
return events to job
}
// ---- Event construction helpers ----
// These use the real event builders from quartz so that tag structures
// stay in sync with the production code. The builder returns an
@@ -323,15 +335,15 @@ class CallManagerTest {
manager.initiateCall(bob, CallType.VOICE, callId, sdpOffer)
assertIs<CallState.Offering>(manager.state.value)
var answerReceived = false
manager.onAnswerReceived = { answerReceived = true }
val (events, job) = collectSessionEvents(manager)
val answer = makeAnswer(from = bob, to = alice)
manager.onSignalingEvent(answer)
val state = manager.state.value
assertIs<CallState.Connecting>(state)
assertTrue(answerReceived, "onAnswerReceived callback should fire")
assertTrue(events.any { it is CallSessionEvent.AnswerReceived }, "AnswerReceived event should be emitted")
job.cancel()
}
// ========================================================================
@@ -461,14 +473,14 @@ class CallManagerTest {
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
manager.acceptCall(sdpAnswer)
var iceCalled = false
manager.onIceCandidateReceived = { iceCalled = true }
val (events, job) = collectSessionEvents(manager)
// ICE candidate from self (echoed back by relay)
val selfIce = makeIceCandidate(from = bob, to = alice)
manager.onSignalingEvent(selfIce)
assertTrue(!iceCalled, "Self ICE candidates MUST be ignored")
assertTrue(events.none { it is CallSessionEvent.IceCandidateReceived }, "Self ICE candidates MUST be ignored")
job.cancel()
}
@Test
@@ -533,13 +545,14 @@ class CallManagerTest {
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
manager.acceptCall(sdpAnswer)
var receivedIce: CallIceCandidateEvent? = null
manager.onIceCandidateReceived = { receivedIce = it }
val (events, job) = collectSessionEvents(manager)
val ice = makeIceCandidate(from = alice, to = bob)
manager.onSignalingEvent(ice)
assertNotNull(receivedIce, "ICE candidate should be forwarded via callback")
val iceEvent = events.filterIsInstance<CallSessionEvent.IceCandidateReceived>().firstOrNull()
assertNotNull(iceEvent, "ICE candidate should be emitted via sessionEvents")
job.cancel()
}
// ========================================================================
@@ -794,15 +807,16 @@ class CallManagerTest {
// Bob still ringing
assertIs<CallState.IncomingCall>(manager.state.value)
// Track mesh setup callback
val newPeers = mutableListOf<HexKey>()
manager.onNewPeerInGroupCall = { newPeers.add(it) }
// Track mesh setup events
val (events, job) = collectSessionEvents(manager)
// Bob accepts
manager.acceptCall(sdpAnswer)
// Should trigger callee-to-callee mesh setup with carol
val newPeers = events.filterIsInstance<CallSessionEvent.NewPeerInGroupCall>().map { it.peerPubKey }
assertTrue(carol in newPeers, "Should discover carol for mesh setup after accepting")
job.cancel()
}
// ========================================================================
@@ -819,19 +833,17 @@ class CallManagerTest {
manager.onPeerConnected()
assertIs<CallState.Connected>(manager.state.value)
var midCallPeer: HexKey? = null
var midCallSdp: String? = null
manager.onMidCallOfferReceived = { peer, sdp ->
midCallPeer = peer
midCallSdp = sdp
}
val (events, job) = collectSessionEvents(manager)
// Carol sends a mid-call offer (callee-to-callee mesh)
val carolOffer = makeOffer(from = carol, to = bob, callId = callId, sdp = "carol-sdp")
manager.onSignalingEvent(carolOffer)
assertEquals(carol, midCallPeer)
assertEquals("carol-sdp", midCallSdp)
val midCallEvent = events.filterIsInstance<CallSessionEvent.MidCallOfferReceived>().firstOrNull()
assertNotNull(midCallEvent)
assertEquals(carol, midCallEvent.peerPubKey)
assertEquals("carol-sdp", midCallEvent.sdpOffer)
job.cancel()
}
// ========================================================================
@@ -882,12 +894,13 @@ class CallManagerTest {
manager.onPeerConnected()
manager.onSignalingEvent(makeAnswer(from = carol, to = alice))
val leftPeers = mutableListOf<HexKey>()
manager.onPeerLeft = { leftPeers.add(it) }
val (events, job) = collectSessionEvents(manager)
manager.onSignalingEvent(makeHangup(from = bob, to = alice))
assertTrue(bob in leftPeers, "onPeerLeft should fire when a peer hangs up")
val leftPeers = events.filterIsInstance<CallSessionEvent.PeerLeft>().map { it.peerPubKey }
assertTrue(bob in leftPeers, "PeerLeft should be emitted when a peer hangs up")
job.cancel()
}
// ========================================================================
@@ -1128,8 +1141,8 @@ class CallManagerTest {
* 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.
* still be emitted via [CallManager.sessionEvents] so the caller-side
* CallSession can unconditionally initiate a mesh offer to Carol.
*/
@Test
fun midCallInviteAnswerFromUnknownPeerInConnectedExpandsMembership() =
@@ -1142,8 +1155,7 @@ class CallManagerTest {
manager.onPeerConnected()
assertIs<CallState.Connected>(manager.state.value)
var forwardedPeer: HexKey? = null
manager.onAnswerReceived = { event -> forwardedPeer = event.pubKey }
val (events, job) = collectSessionEvents(manager)
// Alice invited Carol mid-call; Carol broadcasts her acceptance.
// Bob sees a CallAnswer from Carol (unknown peer, same call-id)
@@ -1155,7 +1167,9 @@ class CallManagerTest {
assertIs<CallState.Connected>(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")
val forwardedPeer = events.filterIsInstance<CallSessionEvent.AnswerReceived>().firstOrNull()?.event?.pubKey
assertEquals(carol, forwardedPeer, "Answer must still be emitted to CallSession")
job.cancel()
}
/**
@@ -1296,16 +1310,17 @@ class CallManagerTest {
// 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 }
// emitted so Bob's CallSession can initiate a mesh offer.
val (bobEvents, bobJob) = collectSessionEvents(bobManager)
bobManager.onSignalingEvent(
makeGroupAnswer(from = carol, members = setOf(alice, bob, carol), sdp = "carol-answer-sdp"),
)
val bobAfterCarolAnswer = bobManager.state.value
assertIs<CallState.Connected>(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")
val bobForwardedAnswer = bobEvents.filterIsInstance<CallSessionEvent.AnswerReceived>().firstOrNull()?.event?.pubKey
assertEquals(carol, bobForwardedAnswer, "Bob must emit Carol's answer to his CallSession")
bobJob.cancel()
}
// ========================================================================
@@ -1528,15 +1543,16 @@ class CallManagerTest {
assertTrue(carol in connecting.pendingPeerPubKeys)
// Carol publishes a mesh offer to Bob (same call-id).
var midCallOfferPeer: HexKey? = null
manager.onMidCallOfferReceived = { peer, _ -> midCallOfferPeer = peer }
val (events, job) = collectSessionEvents(manager)
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")
val midCallOfferPeer = events.filterIsInstance<CallSessionEvent.MidCallOfferReceived>().firstOrNull()?.peerPubKey
assertEquals(carol, midCallOfferPeer, "CallSession must still receive the mid-call offer")
job.cancel()
// And her watchdog should be cancelled — advancing past the
// 30 s budget must not further alter state.
@@ -1929,7 +1945,7 @@ class CallManagerTest {
/**
* Regression for the subtle bug where a self-reject echo would trigger
* onPeerLeft(signer.pubKey) on a device that had already accepted,
* PeerLeft(signer.pubKey) on a device that had already accepted,
* disposing its own PeerSession and killing the live call.
*/
@Test
@@ -1937,8 +1953,7 @@ class CallManagerTest {
runTest {
val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice))
var peerLeftCalled = false
manager.onPeerLeft = { peerLeftCalled = true }
val (events, job) = collectSessionEvents(manager)
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
manager.acceptCall(sdpAnswer)
@@ -1950,29 +1965,29 @@ class CallManagerTest {
val siblingReject = makeReject(from = bob, to = alice)
manager.onSignalingEvent(siblingReject)
// Our live call must survive — the echo must not fire
// onPeerLeft (which would dispose our own PeerSession) and must
// Our live call must survive — the echo must not emit
// PeerLeft (which would dispose our own PeerSession) and must
// not change state.
assertIs<CallState.Connected>(manager.state.value)
assertTrue(
!peerLeftCalled,
"onPeerLeft must not fire for a self-reject echo — the UI would " +
events.none { it is CallSessionEvent.PeerLeft },
"PeerLeft must not be emitted for a self-reject echo — the UI would " +
"tear down our own PeerSession, killing the live call audio.",
)
job.cancel()
}
/**
* Same guard for the Connecting state: a self-reject arriving during
* the connection handshake must not drop us from our own pending set
* and must not invoke onPeerLeft(self).
* and must not emit PeerLeft(self).
*/
@Test
fun selfRejectEchoDuringConnectingDoesNotFireOnPeerLeft() =
runTest {
val (manager, _) = createManager(localPubKey = bob, followedKeys = setOf(alice))
var peerLeftCalled = false
manager.onPeerLeft = { peerLeftCalled = true }
val (events, job) = collectSessionEvents(manager)
manager.onSignalingEvent(makeOffer(from = alice, to = bob))
manager.acceptCall(sdpAnswer)
@@ -1982,7 +1997,8 @@ class CallManagerTest {
manager.onSignalingEvent(siblingReject)
assertIs<CallState.Connecting>(manager.state.value)
assertTrue(!peerLeftCalled, "onPeerLeft must not fire for a self-reject echo during Connecting")
assertTrue(events.none { it is CallSessionEvent.PeerLeft }, "PeerLeft must not be emitted for a self-reject echo during Connecting")
job.cancel()
}
/**