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
@@ -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()
}
/**