@@ -21,11 +21,15 @@ import io.getstream.video.android.core.analytics.call.observer.model.Stage
2121import io.getstream.video.android.core.analytics.reporting.ClientEventReporter
2222import io.getstream.video.android.core.analytics.reporting.model.PeerConnectionRole
2323import io.getstream.video.android.core.call.RtcSession
24+ import io.getstream.video.android.core.call.connection.StreamPeerConnection
2425import kotlinx.coroutines.CoroutineScope
26+ import kotlinx.coroutines.Job
2527import kotlinx.coroutines.flow.StateFlow
28+ import kotlinx.coroutines.flow.distinctUntilChanged
2629import kotlinx.coroutines.flow.filter
2730import kotlinx.coroutines.flow.filterNotNull
2831import kotlinx.coroutines.flow.flatMapLatest
32+ import kotlinx.coroutines.flow.map
2933import kotlinx.coroutines.launch
3034import org.webrtc.PeerConnection
3135
@@ -39,7 +43,7 @@ internal class PeerConnectionAnalytics(
3943 val stateHolder : PeerConnectionAnalyticsStateHolder = PeerConnectionAnalyticsStateHolder (),
4044) {
4145
42- val allowedStates = listOf (
46+ val allowedPcStates = listOf (
4347 PeerConnection .PeerConnectionState .CONNECTING ,
4448 PeerConnection .PeerConnectionState .FAILED ,
4549 PeerConnection .PeerConnectionState .CONNECTED ,
@@ -49,71 +53,91 @@ internal class PeerConnectionAnalytics(
4953 stateHolder.state.value.peerConnectionObserverJob?.cancel()
5054 val peerConnectionObserverJob = observerScope.launch {
5155 stateHolder.state.value.publisherJob?.cancel()
52- val publisherJob = launch {
53- session.filterNotNull()
54- .flatMapLatest { it.publisher.filterNotNull() }
55- .flatMapLatest {
56- it.state.filter { it ->
57- allowedStates.contains(
58- it,
59- )
60- }.filterNotNull()
61- }.filter {
62- val existingStage = stateHolder.state.value.publisherStage
63- val newStage = getStage(it)
64- val isExistingStageAndNewStageAreCompleted = (existingStage == Stage .COMPLETED && newStage == Stage .COMPLETED )
65- ! isExistingStageAndNewStageAreCompleted
66- }
67- .collect { state ->
68- val publisherStage = getStage(state)
69- publisherStage?.let {
70- stateHolder.updatePublisherStage(publisherStage)
71- observerScope.launch {
72- session.value?.publisher?.value?.let { publisher ->
73- onPeerConnectionStateChanged(
74- publisher.hashCode(),
75- role = PeerConnectionRole .PUBLISH ,
76- iceState = publisher.iceState.value,
77- peerConnectionState = state,
78- )
79- }
80- }
81- }
82- }
83- }
56+ val publisherJob = observeConnection(
57+ session = session,
58+ role = PeerConnectionRole .PUBLISH ,
59+ connectionOf = { it.publisher },
60+ currentStage = { stateHolder.state.value.publisherStage },
61+ updateStage = stateHolder::updatePublisherStage,
62+ )
8463 stateHolder.updatePublisherJob(publisherJob)
64+
8565 stateHolder.state.value.subscriberJob?.cancel()
86- val subscriberJob = launch {
87- session.filterNotNull()
88- .flatMapLatest { it.subscriber.filterNotNull() }
89- .flatMapLatest {
90- it.state.filter { it -> allowedStates.contains(it) }.filterNotNull()
91- }.filter {
92- val existingStage = stateHolder.state.value.subscriberStage
66+ val subscriberJob = observeConnection(
67+ session = session,
68+ role = PeerConnectionRole .SUBSCRIBE ,
69+ connectionOf = { it.subscriber },
70+ currentStage = { stateHolder.state.value.subscriberStage },
71+ updateStage = stateHolder::updateSubscriberStage,
72+ )
73+ stateHolder.updateSubscriberJob(subscriberJob)
74+ }
75+ stateHolder.updatePeerConnectionObserverJob(peerConnectionObserverJob)
76+ }
77+
78+ /* *
79+ * Observes a single peer connection ([connectionOf]) for analytics.
80+ *
81+ * Each allowed peer-connection state ([allowedPcStates]) is reported together with the ICE
82+ * state as it stands at that moment ([StreamPeerConnection.iceState] value, mapped via
83+ * [toVideoAnalyticsIceState]). So a CONNECTED peer connection reports CONNECTED only when its
84+ * ICE is already connected, otherwise NOT_CONNECTED. The upstream filter drops a transition
85+ * that would merely repeat an already-completed stage (e.g. CONNECTED after FAILED), and
86+ * [distinctUntilChanged] avoids emitting the same combination twice.
87+ *
88+ * The stage is updated in lockstep with the reported event (in the terminal collector), not
89+ * when the raw peer-connection state changes. It therefore stays IN_PROGRESS while the
90+ * peer-connection session is open and only flips to COMPLETED once the completed event is
91+ * actually emitted, which keeps the call-leave flow's "is a stage still in progress?" check
92+ * correct.
93+ */
94+ private fun CoroutineScope.observeConnection (
95+ session : StateFlow <RtcSession ?>,
96+ role : PeerConnectionRole ,
97+ connectionOf : (RtcSession ) -> StateFlow <StreamPeerConnection ?>,
98+ currentStage : () -> Stage ,
99+ updateStage : (Stage ) -> Unit ,
100+ ): Job = launch {
101+ session.filterNotNull()
102+ .flatMapLatest { connectionOf(it).filterNotNull() }
103+ .flatMapLatest { connection ->
104+ connection.state
105+ .filter { allowedPcStates.contains(it) }
106+ .filterNotNull()
107+ .filter {
108+ // Skip a peer-connection state that would only repeat an already-completed
109+ // stage (e.g. a CONNECTED after a FAILED). Gating here — before the ICE read
110+ // and the collector — means we don't do any work for such transitions.
111+ val existingStage = currentStage()
93112 val newStage = getStage(it)
94113 val isExistingStageAndNewStageAreCompleted = (existingStage == Stage .COMPLETED && newStage == Stage .COMPLETED )
95114 ! isExistingStageAndNewStageAreCompleted
96115 }
97- .collect { state ->
98- val stage = getStage(state)
99- stage?.let {
100- stateHolder.updateSubscriberStage(stage)
101- observerScope.launch {
102- session.value?.subscriber?.value?.let { subscriber ->
103- onPeerConnectionStateChanged(
104- subscriber.hashCode(),
105- role = PeerConnectionRole .SUBSCRIBE ,
106- iceState = subscriber.iceState.value,
107- peerConnectionState = state,
108- )
109- }
110- }
111- }
116+ .map { pcState ->
117+ PeerConnectionSnapshot (
118+ connection.hashCode(),
119+ pcState,
120+ connection.iceState.value.toVideoAnalyticsIceState(),
121+ )
112122 }
113123 }
114- stateHolder.updateSubscriberJob(subscriberJob)
115- }
116- stateHolder.updatePeerConnectionObserverJob(peerConnectionObserverJob)
124+ .distinctUntilChanged()
125+ .collect { snapshot ->
126+ // Update the stage in lockstep with the reported event (not when the raw
127+ // peer-connection state changed), so it only flips to COMPLETED once the completed
128+ // event is actually emitted. The COMPLETED -> COMPLETED case is already dropped by
129+ // the upstream filter, so no extra guard is needed here.
130+ val pcAnalyticsStage = getStage(snapshot.peerConnectionState)
131+ pcAnalyticsStage?.let {
132+ updateStage(pcAnalyticsStage)
133+ onPeerConnectionStateChanged(
134+ peerConnectionHashCode = snapshot.peerConnectionHashCode,
135+ role = role,
136+ iceState = snapshot.iceState,
137+ peerConnectionState = snapshot.peerConnectionState,
138+ )
139+ }
140+ }
117141 }
118142
119143 private fun getStage (peerConnectionState : PeerConnection .PeerConnectionState ): Stage ? {
@@ -135,7 +159,7 @@ internal class PeerConnectionAnalytics(
135159 internal fun onPeerConnectionStateChanged (
136160 peerConnectionHashCode : Int ,
137161 role : PeerConnectionRole ,
138- iceState : PeerConnection . IceConnectionState ? ,
162+ iceState : VideoAnalyticsIceState ,
139163 peerConnectionState : PeerConnection .PeerConnectionState ? ,
140164 ) {
141165 reporter.onPeerConnectionStateChanged(
@@ -162,3 +186,9 @@ internal class PeerConnectionAnalytics(
162186 observePeerConnections(session)
163187 }
164188}
189+
190+ private data class PeerConnectionSnapshot (
191+ val peerConnectionHashCode : Int ,
192+ val peerConnectionState : PeerConnection .PeerConnectionState ,
193+ val iceState : VideoAnalyticsIceState ,
194+ )
0 commit comments