diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt index 3b734754ade..8e0b4269b68 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/call/CallScreen.kt @@ -115,7 +115,9 @@ import io.getstream.video.android.compose.ui.components.call.renderer.RegularVid import io.getstream.video.android.compose.ui.components.call.renderer.copy import io.getstream.video.android.compose.ui.components.video.VideoScalingType import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallLeaveReason import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.UserActionCause import io.getstream.video.android.core.call.state.ChooseLayout import io.getstream.video.android.core.model.PreferredVideoResolution import io.getstream.video.android.core.pip.PictureInPictureConfiguration @@ -371,7 +373,12 @@ fun CallScreen( LeaveCallAction( modifier = Modifier.testTag("Stream_HangUpButton"), ) { - call.leave() + // for call join analytics purpose + call.leave( + CallLeaveReason.UserAction( + UserActionCause.CANCELLED_BY_SELF, + ), + ) } }, ) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index bb7c6227149..1ef85740a88 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -2457,14 +2457,14 @@ public final class io/getstream/android/video/generated/models/ChatPreferencesRe public final class io/getstream/android/video/generated/models/ClientEvent { public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Ljava/lang/String; public final fun component11 ()Ljava/lang/String; - public final fun component12 ()Lorg/threeten/bp/OffsetDateTime; - public final fun component13 ()Ljava/lang/Integer; - public final fun component14 ()Ljava/lang/String; + public final fun component12 ()Ljava/lang/String; + public final fun component13 ()Lorg/threeten/bp/OffsetDateTime; + public final fun component14 ()Ljava/lang/Integer; public final fun component15 ()Ljava/lang/String; public final fun component16 ()Ljava/lang/String; public final fun component17 ()Ljava/lang/String; @@ -2472,22 +2472,23 @@ public final class io/getstream/android/video/generated/models/ClientEvent { public final fun component19 ()Ljava/lang/String; public final fun component2 ()Ljava/lang/String; public final fun component20 ()Ljava/lang/String; - public final fun component21 ()Lorg/threeten/bp/OffsetDateTime; - public final fun component22 ()Ljava/lang/String; + public final fun component21 ()Ljava/lang/String; + public final fun component22 ()Lorg/threeten/bp/OffsetDateTime; public final fun component23 ()Ljava/lang/String; public final fun component24 ()Ljava/lang/String; public final fun component25 ()Ljava/lang/String; public final fun component26 ()Ljava/lang/String; - public final fun component27 ()Ljava/lang/Boolean; + public final fun component27 ()Ljava/lang/String; + public final fun component28 ()Ljava/lang/Boolean; public final fun component3 ()Ljava/lang/String; - public final fun component4 ()Ljava/lang/Integer; - public final fun component5 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/Integer; public final fun component6 ()Ljava/lang/String; public final fun component7 ()Ljava/lang/String; public final fun component8 ()Ljava/lang/String; public final fun component9 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)Lio/getstream/android/video/generated/models/ClientEvent; - public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/ClientEvent;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/ClientEvent; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;)Lio/getstream/android/video/generated/models/ClientEvent; + public static synthetic fun copy$default (Lio/getstream/android/video/generated/models/ClientEvent;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lorg/threeten/bp/OffsetDateTime;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Boolean;ILjava/lang/Object;)Lio/getstream/android/video/generated/models/ClientEvent; public fun equals (Ljava/lang/Object;)Z public final fun getCallSessionId ()Ljava/lang/String; public final fun getCameraPermissionStatus ()Ljava/lang/String; @@ -2497,6 +2498,7 @@ public final class io/getstream/android/video/generated/models/ClientEvent { public final fun getIceState ()Ljava/lang/String; public final fun getId ()Ljava/lang/String; public final fun getJoinAttemptId ()Ljava/lang/String; + public final fun getJoinReason ()Ljava/lang/String; public final fun getMicrophonePermissionStatus ()Ljava/lang/String; public final fun getOutcome ()Ljava/lang/String; public final fun getPeerConnection ()Ljava/lang/String; diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/ClientEvent.kt b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/ClientEvent.kt index 1c9c0c5af27..583dfb1b8df 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/ClientEvent.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/android/video/generated/models/ClientEvent.kt @@ -48,6 +48,9 @@ data class ClientEvent ( @Json(name = "coordinator_connect_id") val coordinatorConnectId: kotlin.String? = null, + @Json(name = "join_reason") + val joinReason: kotlin.String? = null, + @Json(name = "elapsed_time") val elapsedTime: kotlin.Int? = null, @@ -133,6 +136,7 @@ data class ClientEvent ( appendIfNotNull("stageId", stageId) appendIfNotNull("userSessionId", userSessionId) appendIfNotNull("joinAttemptId", joinAttemptId) + appendIfNotNull("joinReason", joinReason) appendIfNotNull("coordinatorConnectId", coordinatorConnectId) appendIfNotNull("userId", userId) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index e42e254a0a0..5243867ef84 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -60,6 +60,10 @@ import io.getstream.result.Result import io.getstream.result.Result.Failure import io.getstream.result.Result.Success import io.getstream.result.flatMap +import io.getstream.video.android.core.analytics.call.CallAnalytics +import io.getstream.video.android.core.analytics.call.observer.model.JoinAnalyticsModel +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.reporting.model.AnalyticsCallAbortReason import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.call.FastReconnectResult import io.getstream.video.android.core.call.RtcSession @@ -313,6 +317,18 @@ public class Call( _peerConnectionFactory = value } + internal val callAnalytics = + CallAnalytics( + clientImpl.context, + this.id, + this.type, + state.me, + state.connection, + state.participants, + client.state.clientEventReporter, + scope, + ) + /** * Checks if the audioBitrateProfile has changed since the factory was created, * and recreates the factory if needed. This should only be called before joining. @@ -551,6 +567,8 @@ public class Call( hintHighScaleLivestreamPublisher: Boolean? = null, callJoinInterceptor: CallJoinInterceptor? = null, ): Result { + callAnalytics.joinAnalytics.onJoinFunctionStart() + callAnalytics.mediaPermissionObserver.mediaPermissionStatus() logger.d { "[join] #ringing; #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" } @@ -585,7 +603,14 @@ public class Call( atomicLeave = AtomicUnitCall() while (retryCount < 3) { - result = _join(create, createOptions, ring, notify, hintHighScaleLivestreamPublisher) + result = _join( + create, + createOptions, + ring, + notify, + hintHighScaleLivestreamPublisher, + JoinAnalyticsModel(retryCount, JoinReason.FirstAttempt), + ) if (result is Success) { // we initialise the camera, mic and other according to local + backend settings // only when the call is joined to make sure we don't switch and override @@ -606,6 +631,11 @@ public class Call( logger.e { "Join failed with error $result" } if (isPermanentError(result.value)) { state._connection.value = RealtimeConnection.Failed(result.value) + callAnalytics.joinAnalytics.onJoinRequestPermanentError( + retryCount, + AnalyticsCallAbortReason.SERVER_ERROR.name, + result.value.message, + ) return result } else { retryCount += 1 @@ -616,6 +646,11 @@ public class Call( session.value = null val errorMessage = "Join failed after 3 retries" state._connection.value = RealtimeConnection.Failed(errorMessage) + callAnalytics.joinAnalytics.onJoinRequestRetryExhausted( + retryCount, + AnalyticsCallAbortReason.RETRY_EXHAUSTED.name, + errorMessage, + ) return Failure(value = Error.GenericError(errorMessage)) } @@ -665,6 +700,7 @@ public class Call( ring: Boolean = false, notify: Boolean = false, hintHighScaleLivestreamPublisher: Boolean? = null, + joinAnalyticsModel: JoinAnalyticsModel, ): Result { nonFastReconnectAttempts = 0 sfuEvents?.cancel() @@ -699,6 +735,7 @@ public class Call( ring = ring, notify = notify, hintHighScaleLivestreamPublisher = hintHighScaleLivestreamPublisher, + joinAnalyticsModel = joinAnalyticsModel, ) if (result !is Success) { @@ -724,6 +761,11 @@ public class Call( sfuName = sfuName, remoteIceServers = iceServers, powerManager = powerManager, + sfuAnalytics = callAnalytics.sfuAnalytics.apply { + sfuAnalyticsStateHolder.updateSfuId( + sfuName, + ) + }, ) } session.value = localSession @@ -762,7 +804,11 @@ public class Call( } } monitorPublisherPCStateJob?.cancel() - + callAnalytics.peerConnectionAnalytics.stopAndObservePeerConnections(session) + callAnalytics.audioAnalytics.observeFirstRemoteParticipantAudioMuteState( + session, + state.participants, + ) monitorPublisherPCStateJob = scope.launch { session .filterNotNull() @@ -973,12 +1019,17 @@ public class Call( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN -> { nonFastReconnectAttempts++ - reconnectRejoin(reason) + reconnectRejoin( + reason, + JoinAnalyticsModel(nonFastReconnectAttempts, JoinReason.ReJoin), + ) } WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE -> { nonFastReconnectAttempts++ - reconnectMigrate() + reconnectMigrate( + JoinAnalyticsModel(nonFastReconnectAttempts, JoinReason.Migrate), + ) } WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_DISCONNECT -> @@ -1033,12 +1084,18 @@ public class Call( } if (state.connection.value is RealtimeConnection.ReconnectingFailed) { - logger.w { "[reconnect] All recovery attempts exhausted — leaving call ($reason)" } + val message = "[reconnect] All recovery attempts exhausted — leaving call ($reason)" + logger.w { message } + callAnalytics.joinAnalytics.onJoinRequestRetryExhausted( + loopIteration, + AnalyticsCallAbortReason.RETRY_EXHAUSTED.name, + message, + ) leave( CallLeaveReason.RetryExhausted( loopIteration, "reconnect-failed", - "All recovery attempts exhausted — leaving call ($reason)", + message, ), ) } @@ -1090,7 +1147,10 @@ public class Call( * previous_session_id is set so the SFU can transfer state (tracks, * subscriptions) from the old session to the new one. */ - private suspend fun reconnectRejoin(reason: String): ReconnectOutcome { + private suspend fun reconnectRejoin( + reason: String, + joinAnalyticsModel: JoinAnalyticsModel, + ): ReconnectOutcome { logger.d { "[reconnectRejoin] reconnectAttempts=$nonFastReconnectAttempts" } state._connection.value = RealtimeConnection.Reconnecting val loc = location @@ -1099,7 +1159,7 @@ public class Call( ?: return ReconnectOutcome.PreconditionNotMet("No active session for rejoin") reconnectStartTime = System.currentTimeMillis() - val joinResponse = joinRequest(location = loc) + val joinResponse = joinRequest(location = loc, joinAnalyticsModel = joinAnalyticsModel) if (joinResponse !is Success) { return ReconnectOutcome.Failed( Exception("Failed to get join response: ${joinResponse.errorOrNull()}"), @@ -1135,10 +1195,21 @@ public class Call( cred.token, cred.server.edgeName, cred.iceServers.map { ice -> ice.toIceServer() }, + sfuAnalytics = callAnalytics.sfuAnalytics.apply { + sfuAnalyticsStateHolder.updateSfuId( + cred.server.edgeName, + ) + }, ) this.session.value = newSession - return when (val result = newSession.connectInternal(reconnectDetails, currentOptions)) { + return when ( + val result = newSession.connectInternal( + reconnectDetails, + currentOptions, + JoinAnalyticsModel(joinAnalyticsModel.retryAttempt), + ) + ) { is SfuConnectionResult.Connected -> { newSession.sfuTracer.trace("rejoin", reason) monitorSession(joinResponse.value) @@ -1152,7 +1223,7 @@ public class Call( * Migrate to another SFU. Reuses the same session ID — the SFU * identifies the participant via from_sfu_id, not previous_session_id. */ - private suspend fun reconnectMigrate(): ReconnectOutcome { + private suspend fun reconnectMigrate(joinAnalyticsModel: JoinAnalyticsModel): ReconnectOutcome { logger.d { "[reconnectMigrate] Migrating" } state._connection.value = RealtimeConnection.Migrating val loc = location @@ -1162,7 +1233,12 @@ public class Call( reconnectStartTime = System.currentTimeMillis() addFailedSfuId(oldSession.sfuName) - val joinResponse = joinRequest(location = loc, migratingFrom = oldSession.sfuName) + val joinResponse = + joinRequest( + location = loc, + migratingFrom = oldSession.sfuName, + joinAnalyticsModel = joinAnalyticsModel, + ) if (joinResponse !is Success) { return ReconnectOutcome.Failed( Exception( @@ -1203,11 +1279,20 @@ public class Call( cred.token, cred.server.edgeName, cred.iceServers.map { ice -> ice.toIceServer() }, + sfuAnalytics = callAnalytics.sfuAnalytics.apply { + sfuAnalyticsStateHolder.updateSfuId( + cred.server.edgeName, + ) + }, ) this.session.value = newSession return try { - val result = newSession.connectInternal(reconnectDetails, currentOptions) + val result = newSession.connectInternal( + reconnectDetails, + currentOptions, + JoinAnalyticsModel(joinAnalyticsModel.retryAttempt), + ) when (result) { is SfuConnectionResult.Connected -> { monitorSession(joinResponse.value) @@ -1249,6 +1334,7 @@ public class Call( private fun internalLeave(reason: CallLeaveReason) = atomicLeave { monitorSubscriberPCStateJob?.cancel() monitorPublisherPCStateJob?.cancel() + callAnalytics.stopObservers() monitorPublisherPCStateJob = null monitorSubscriberPCStateJob = null leaveTimeoutAfterDisconnect?.cancel() @@ -1287,6 +1373,7 @@ public class Call( clientImpl.scope.launch { val leaveReason = "[reason=${reason::class.simpleName}, message=${reason.message}]" + callAnalytics.onCallLeave(session, reason) safeCall { session.value?.sfuTracer?.trace("leave-call", leaveReason) val stats = collectStats() @@ -1448,6 +1535,15 @@ public class Call( ) } onRendered(videoRenderer) + callAnalytics.videoAnalytics.firstVideoFrameRendered( + + trackType, + width, + height, + rtcSession = session.value, + sessionId, + this@Call.sessionId, + ) } override fun onFrameResolutionChanged( @@ -1826,8 +1922,10 @@ public class Call( ring: Boolean = false, notify: Boolean = false, hintHighScaleLivestreamPublisher: Boolean? = null, + joinAnalyticsModel: JoinAnalyticsModel, ): Result { val migratingFromList = migratingFromList ?: getFailedSfuIdsSnapshot().takeIf { it.isNotEmpty() } + callAnalytics.joinAnalytics.onJoinRequestStart(joinAnalyticsModel.joinReason) val result = clientImpl.joinCall( type, id, create = create != null, @@ -1844,6 +1942,10 @@ public class Call( hintHighScaleLivestreamPublisher = hintHighScaleLivestreamPublisher, ) result.onSuccess { + callAnalytics.joinAnalytics.onJoinRequestSuccess( + joinAnalyticsModel, + it.call.currentSessionId, + ) state.updateFromResponse(it) } return result diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt index 1ae240d78d3..364a3a3ab0b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt @@ -88,6 +88,8 @@ class ClientState(private val client: StreamVideo) { public val callConfigRegistry = (client as StreamVideoClient).callServiceConfigRegistry private val serviceLauncher = ServiceLauncher(client.context) + internal val clientEventReporter = (client as StreamVideoClient).clientEventReporter + @InternalStreamVideoApi public val rejectCallWhenBusy: Boolean = (client as StreamVideoClient).rejectCallWhenBusy diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt index 890ed1723c6..59d47d7c478 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt @@ -83,6 +83,8 @@ import io.getstream.result.Error import io.getstream.result.Result import io.getstream.result.Result.Failure import io.getstream.result.Result.Success +import io.getstream.video.android.core.analytics.coordinator.CoordinatorAnalytics +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter import io.getstream.video.android.core.audio.AudioExecutionContext import io.getstream.video.android.core.call.CallBusyHandler import io.getstream.video.android.core.errors.VideoErrorCode @@ -194,6 +196,9 @@ internal class StreamVideoClient internal constructor( internal val enableStereoForSubscriber: Boolean = true, internal val telecomConfig: TelecomConfig? = null, internal val rejectCallWhenBusy: Boolean = false, + internal val clientEventReporter: ClientEventReporter = ClientEventReporter.getDefault( + coordinatorConnectionModule.api, + ), ) : StreamVideo, NotificationHandler by streamNotificationManager { private var locationJob: Deferred>? = null @@ -231,7 +236,7 @@ internal class StreamVideoClient internal constructor( internal fun getAudioContext(): AudioExecutionContext = audioExecutionContext val socketImpl = coordinatorConnectionModule.socketConnection - + val coordinatorAnalytics = CoordinatorAnalytics(scope, clientEventReporter) fun onCallCleanUp(call: Call) { if (enableCallUpdatesAfterLeave) { logger.d { "[cleanup] Call updates are required, preserve the instance: ${call.cid}" } @@ -268,6 +273,7 @@ internal class StreamVideoClient internal constructor( CallLeaveReason.SdkDriven(cause = SdkCause.CLIENT_CLEANUP), ) audioExecutionContext.release() + coordinatorAnalytics.endObserver() } /** @@ -425,6 +431,11 @@ internal class StreamVideoClient internal constructor( } } + scope.launch { + coordinatorAnalytics + .startObserver(coordinatorConnectionModule.socketConnection.state()) + } + scope.launch(CoroutineName("init#coordinatorSocket.errors.collect")) { coordinatorConnectionModule.socketConnection.errors().collect { error -> state.handleError(error.streamError) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt new file mode 100644 index 00000000000..be91a5909ca --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call + +import android.content.Context +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.ParticipantState +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.analytics.call.observer.AudioAnalytics +import io.getstream.video.android.core.analytics.call.observer.JoinAnalytics +import io.getstream.video.android.core.analytics.call.observer.JoinAnalyticsStateHolder +import io.getstream.video.android.core.analytics.call.observer.MediaPermissionObserver +import io.getstream.video.android.core.analytics.call.observer.PeerConnectionAnalytics +import io.getstream.video.android.core.analytics.call.observer.SfuAnalytics +import io.getstream.video.android.core.analytics.call.observer.SfuAnalyticsStateHolder +import io.getstream.video.android.core.analytics.call.observer.VideoAnalytics +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.analytics.reporting.model.AnalyticsCallAbortReason +import io.getstream.video.android.core.call.RtcSession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +internal class CallAnalytics( + val context: Context, + val callId: String, + val callType: String, + val myParticipantState: StateFlow, + val connectionFlow: StateFlow, + val participants: StateFlow>, + val eventReporter: ClientEventReporter, + val observerScope: CoroutineScope, +) { + val logger by taggedLogger("CallAnalyticsHooks") + + val joinAnalyticsStateHolder = JoinAnalyticsStateHolder() + val sfuAnalyticsStateHolder = SfuAnalyticsStateHolder() + + val joinAnalytics = JoinAnalytics(callId, callType, eventReporter, joinAnalyticsStateHolder) { + resetAfterJoinSuccess() + } + val sfuAnalytics = + SfuAnalytics( + true, + callId, + callType, + connectionFlow, + observerScope, + eventReporter, + joinAnalyticsStateHolder, + sfuAnalyticsStateHolder, + ) + val peerConnectionAnalytics = + PeerConnectionAnalytics( + callId, + callType, + observerScope, + eventReporter, + joinAnalyticsStateHolder, + sfuAnalyticsStateHolder, + ) + val mediaPermissionObserver = + MediaPermissionObserver(context, callId, callType, eventReporter, joinAnalyticsStateHolder) + + val audioAnalytics = + AudioAnalytics( + callId, + callType, + eventReporter, + joinAnalyticsStateHolder, + sfuAnalyticsStateHolder, + observerScope, + ) + val videoAnalytics = + VideoAnalytics( + callId, + callType, + myParticipantState, + eventReporter, + joinAnalyticsStateHolder, + sfuAnalyticsStateHolder, + ) + + fun resetAfterJoinSuccess() { + videoAnalytics.reset() + audioAnalytics.reset() + } + + fun onCallLeave(session: StateFlow, callLeaveReason: CallLeaveReason) { + val isAnyStageInProgress = + joinAnalytics.joinAnalyticsStateHolder.state.value.joinStage == Stage.IN_PROGRESS || + sfuAnalytics.sfuAnalyticsStateHolder.stage.value == Stage.IN_PROGRESS || + peerConnectionAnalytics.stateHolder.state.value.publisherStage == Stage.IN_PROGRESS || + peerConnectionAnalytics.stateHolder.state.value.subscriberStage == Stage.IN_PROGRESS + + if (isAnyStageInProgress) { + val pairCodeWithMessage = when (callLeaveReason) { + is CallLeaveReason.Backend -> { + if (callLeaveReason.cause == io.getstream.video.android.core.BackendCause.LEAVE_TIMEOUT_AFTER_DISCONNECT) { + Pair(AnalyticsCallAbortReason.NETWORK_OFFLINE, callLeaveReason.message) + } else { + Pair(AnalyticsCallAbortReason.BACKEND_LEAVE, callLeaveReason.message) + } + } + is CallLeaveReason.UserAction -> { + Pair(AnalyticsCallAbortReason.CLIENT_ABORTED, callLeaveReason.message) + } + is CallLeaveReason.RetryExhausted -> { + Pair(AnalyticsCallAbortReason.RETRY_EXHAUSTED, callLeaveReason.message) + } + is CallLeaveReason.SdkDriven -> { + Pair(AnalyticsCallAbortReason.SDK_DRIVEN_ANDROID, callLeaveReason.message) + } + is CallLeaveReason.Custom -> { + Pair(AnalyticsCallAbortReason.CUSTOM, callLeaveReason.message) + } + } + val publisherIceState = session.value?.publisher?.value?.iceState?.value + val subscriberIceState = session.value?.subscriber?.value?.iceState?.value + eventReporter.abortAllPostCallInFlight( + publisherIceState, + subscriberIceState, + pairCodeWithMessage.first.name, + pairCodeWithMessage.second, + ) + } + } + + fun stopObservers() { + peerConnectionAnalytics.stop() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/AudioAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/AudioAnalytics.kt new file mode 100644 index 00000000000..a0359897389 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/AudioAnalytics.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.ParticipantState +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.call.RtcSession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.webrtc.PeerConnection +import java.util.concurrent.atomic.AtomicBoolean + +private typealias TrackId = String + +internal class AudioAnalytics( + private val callId: String, + private val callType: String, + private val clientEventReporter: ClientEventReporter, + private val joinAnalyticsStateHolder: JoinAnalyticsStateHolder, + private val sfuStateHolder: SfuAnalyticsStateHolder, + private val observerScope: CoroutineScope, +) { + + var recordedFirstFrame: AtomicBoolean = AtomicBoolean(false) + + private var observeJob: Job? = null + + fun observeFirstRemoteParticipantAudioMuteState( + session: StateFlow, + participants: StateFlow>, + ) { + observeJob?.cancel() + observeJob = observerScope.launch { + session.filterNotNull() + .flatMapLatest { it.subscriber.filterNotNull() } + .flatMapLatest { subscriber -> + subscriber.state + .filter { it == PeerConnection.PeerConnectionState.CONNECTED } + .map { subscriber } + } + .flatMapLatest { + participants + .map { list -> list.firstOrNull { participant -> !participant.isLocal } } + .distinctUntilChangedBy { participant -> participant?.sessionId } + .flatMapLatest { participant -> + if (participant == null) { + flowOf(null) + } else { + participant.audioEnabled.map { enabled -> !enabled } + } + } + } + .distinctUntilChanged() + .collect { isMuted -> + val notMuted = isMuted == false + if (notMuted && recordedFirstFrame.compareAndSet(false, true)) { + reportAndCleanup() + } + } + } + } + + private fun reportAndCleanup() { + clientEventReporter.reportFirstAudioFrameRendered( + sfuStateHolder.sfuId.value, + callId, + callType, + joinStageAttemptId = joinAnalyticsStateHolder.state.value.joinStageAttemptId ?: "unknown", + joinReason = joinAnalyticsStateHolder.state.value.joinReason ?: JoinReason.Unknown, + callSessionId = joinAnalyticsStateHolder.state.value.callSessionId, + ) + observeJob?.cancel() + observeJob = null + } + + fun reset() { + recordedFirstFrame.set(false) + observeJob?.cancel() + observeJob = null + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalytics.kt new file mode 100644 index 00000000000..84f2636489a --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalytics.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.analytics.call.observer.model.JoinAnalyticsModel +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import java.util.UUID + +internal class JoinAnalytics( + val callId: String, + val callType: String, + val eventReporter: ClientEventReporter, + val joinAnalyticsStateHolder: JoinAnalyticsStateHolder, + val onJoinSuccess: () -> Unit, +) { + + fun onJoinFunctionStart() { + val stageAttemptId = UUID.randomUUID().toString() + joinAnalyticsStateHolder.updateJoinStageAttemptId(stageAttemptId) + eventReporter.reportSdkMethodJoinInitiated( + callType = callType, + callId = callId, + joinStageAttemptId = stageAttemptId, + ) + } + + fun onJoinRequestStart(joinReason: JoinReason?) { + if (joinReason != JoinReason.FirstAttempt) { + val stageAttemptId = UUID.randomUUID().toString() + joinAnalyticsStateHolder.updateJoinStageAttemptId(stageAttemptId) + } + when (joinAnalyticsStateHolder.state.value.joinStage) { + Stage.NOT_STARTED, Stage.COMPLETED -> { + joinAnalyticsStateHolder.updateJoinReason(joinReason) + val stageId = eventReporter.reportCoordinatorJoinInitiated( + callType = callType, + callId = callId, + joinStageAttemptId = joinAnalyticsStateHolder.state.value.joinStageAttemptId + ?: "unknown", + joinReason = joinReason ?: JoinReason.Unknown, + ) + joinAnalyticsStateHolder.updateStageId(stageId) + joinAnalyticsStateHolder.updateStage(Stage.IN_PROGRESS) + } + + else -> {} + } + } + + fun onJoinRequestSuccess(joinAnalyticsModel: JoinAnalyticsModel, currentSessionId: String) { + when (joinAnalyticsStateHolder.state.value.joinStage) { + Stage.IN_PROGRESS -> { + joinAnalyticsStateHolder.updateCallSessionId(currentSessionId) + if (joinAnalyticsStateHolder.state.value.stageId.isNotEmpty()) { + eventReporter.reportCoordinatorJoinCompleted( + stageId = joinAnalyticsStateHolder.state.value.stageId, + success = true, + retryCount = joinAnalyticsModel.retryAttempt, + callSessionId = currentSessionId, + ) + onJoinSuccess() + } + joinAnalyticsStateHolder.updateStage(Stage.COMPLETED) + } + else -> {} + } + } + + fun onJoinRequestPermanentError(retryCount: Int, failureCode: String, message: String) { + when (joinAnalyticsStateHolder.state.value.joinStage) { + Stage.IN_PROGRESS -> { + if (joinAnalyticsStateHolder.state.value.stageId.isNotEmpty()) { + eventReporter.reportCoordinatorJoinCompleted( + stageId = joinAnalyticsStateHolder.state.value.stageId, + success = false, + retryCount = retryCount, + failureReason = message, + failureCode = failureCode, + ) + } + joinAnalyticsStateHolder.updateStage(Stage.COMPLETED) + } + else -> {} + } + } + + fun onJoinRequestRetryExhausted(retryCount: Int, failureCode: String, message: String) { + onJoinRequestPermanentError(retryCount, failureCode, message) + } + + fun resetStage() { + joinAnalyticsStateHolder.updateStage(Stage.NOT_STARTED) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsStateHolder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsStateHolder.kt new file mode 100644 index 00000000000..68cb148633f --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsStateHolder.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +internal class JoinAnalyticsStateHolder { + + private val _state = MutableStateFlow(JoinTelemetryState()) + val state: StateFlow = _state.asStateFlow() + + fun updateJoinReason(reason: JoinReason?) { + _state.update { current -> + current.copy(joinReason = reason) + } + } + + fun updateJoinStageAttemptId(attemptId: String?) { + _state.update { current -> + current.copy(joinStageAttemptId = attemptId) + } + } + + fun update( + reason: JoinReason?, + attemptId: String?, + ) { + _state.update { + it.copy( + joinReason = reason, + joinStageAttemptId = attemptId, + ) + } + } + + fun updateStageId(stageId: String) { + _state.update { + it.copy( + stageId = stageId, + ) + } + } + + fun updateStage(stage: Stage) { + _state.update { + it.copy( + joinStage = stage, + ) + } + } + + fun updateCallSessionId(callSessionId: String) { + _state.update { + it.copy( + callSessionId = callSessionId, + ) + } + } + + fun reset() { + _state.value = JoinTelemetryState() + } +} + +internal data class JoinTelemetryState( + val joinReason: JoinReason? = null, + val joinStageAttemptId: String? = null, + var stageId: String = "", + val joinStage: Stage = Stage.NOT_STARTED, + val callSessionId: String = "unknown", +) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/MediaPermissionObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/MediaPermissionObserver.kt new file mode 100644 index 00000000000..493c25f0b4a --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/MediaPermissionObserver.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter + +internal class MediaPermissionObserver( + val context: Context, + val callId: String, + val callType: String, + val eventReporter: ClientEventReporter, + val joinAnalyticsStateHolder: JoinAnalyticsStateHolder, +) { + + fun mediaPermissionStatus() { + eventReporter.reportMediaPermissionStatus( + callId, + callType, + joinAnalyticsStateHolder.state.value.joinStageAttemptId ?: "unknown", + joinAnalyticsStateHolder.state.value.joinReason ?: JoinReason.Unknown, + isCameraPermissionGranted(), + isMicrophonePermissionGranted(), + ) + } + + fun isMicrophonePermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO, + ) == PackageManager.PERMISSION_GRANTED + } + + fun isCameraPermissionGranted(): Boolean { + return ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalytics.kt new file mode 100644 index 00000000000..3f491585ad2 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalytics.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.analytics.reporting.model.PeerConnectionRole +import io.getstream.video.android.core.call.RtcSession +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.launch +import org.webrtc.PeerConnection + +internal class PeerConnectionAnalytics( + val callId: String, + val callType: String, + private val observerScope: CoroutineScope, + val reporter: ClientEventReporter, + val joinAnalyticsStateHolder: JoinAnalyticsStateHolder, + val sfuAnalyticsStateHolder: SfuAnalyticsStateHolder, + val stateHolder: PeerConnectionAnalyticsStateHolder = PeerConnectionAnalyticsStateHolder(), +) { + + val allowedStates = listOf( + PeerConnection.PeerConnectionState.CONNECTING, + PeerConnection.PeerConnectionState.FAILED, + PeerConnection.PeerConnectionState.CONNECTED, + ) + + fun observePeerConnections(session: StateFlow) { + stateHolder.state.value.peerConnectionObserverJob?.cancel() + val peerConnectionObserverJob = observerScope.launch { + stateHolder.state.value.publisherJob?.cancel() + val publisherJob = launch { + session.filterNotNull() + .flatMapLatest { it.publisher.filterNotNull() } + .flatMapLatest { + it.state.filter { it -> + allowedStates.contains( + it, + ) + }.filterNotNull() + }.filter { + val existingStage = stateHolder.state.value.publisherStage + val newStage = getStage(it) + val isExistingStageAndNewStageAreCompleted = (existingStage == Stage.COMPLETED && newStage == Stage.COMPLETED) + !isExistingStageAndNewStageAreCompleted + } + .collect { state -> + val publisherStage = getStage(state) + publisherStage?.let { + stateHolder.updatePublisherStage(publisherStage) + observerScope.launch { + session.value?.publisher?.value?.let { publisher -> + onPeerConnectionStateChanged( + publisher.hashCode(), + role = PeerConnectionRole.PUBLISH, + iceState = publisher.iceState.value, + peerConnectionState = state, + ) + } + } + } + } + } + stateHolder.updatePublisherJob(publisherJob) + stateHolder.state.value.subscriberJob?.cancel() + val subscriberJob = launch { + session.filterNotNull() + .flatMapLatest { it.subscriber.filterNotNull() } + .flatMapLatest { + it.state.filter { it -> allowedStates.contains(it) }.filterNotNull() + }.filter { + val existingStage = stateHolder.state.value.subscriberStage + val newStage = getStage(it) + val isExistingStageAndNewStageAreCompleted = (existingStage == Stage.COMPLETED && newStage == Stage.COMPLETED) + !isExistingStageAndNewStageAreCompleted + } + .collect { state -> + val stage = getStage(state) + stage?.let { + stateHolder.updateSubscriberStage(stage) + observerScope.launch { + session.value?.subscriber?.value?.let { subscriber -> + onPeerConnectionStateChanged( + subscriber.hashCode(), + role = PeerConnectionRole.SUBSCRIBE, + iceState = subscriber.iceState.value, + peerConnectionState = state, + ) + } + } + } + } + } + stateHolder.updateSubscriberJob(subscriberJob) + } + stateHolder.updatePeerConnectionObserverJob(peerConnectionObserverJob) + } + + private fun getStage(peerConnectionState: PeerConnection.PeerConnectionState): Stage? { + return when (peerConnectionState) { + PeerConnection.PeerConnectionState.CONNECTING -> { + Stage.IN_PROGRESS // initiated + } + + PeerConnection.PeerConnectionState.FAILED, + PeerConnection.PeerConnectionState.CONNECTED, + -> { + Stage.COMPLETED // completed + } + + else -> { null } + } + } + + internal fun onPeerConnectionStateChanged( + peerConnectionHashCode: Int, + role: PeerConnectionRole, + iceState: PeerConnection.IceConnectionState?, + peerConnectionState: PeerConnection.PeerConnectionState?, + ) { + reporter.onPeerConnectionStateChanged( + peerConnectionHashCode = peerConnectionHashCode, + callId = callId, + callType = callType, + role = role, + iceState = iceState, + peerConnectionState = peerConnectionState, + joinStageAttemptId = joinAnalyticsStateHolder.state.value.joinStageAttemptId ?: "unknown", + joinReason = joinAnalyticsStateHolder.state.value.joinReason ?: JoinReason.Unknown, + sfuId = sfuAnalyticsStateHolder.sfuId.value, + callSessionId = joinAnalyticsStateHolder.state.value.callSessionId, + ) + } + + fun stop() { + stateHolder.state.value.peerConnectionObserverJob?.cancel() + stateHolder.updatePeerConnectionObserverJob(null) + } + + fun stopAndObservePeerConnections(session: StateFlow) { + stop() + observePeerConnections(session) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsStateHolder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsStateHolder.kt new file mode 100644 index 00000000000..e16ac1cce89 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsStateHolder.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +internal class PeerConnectionAnalyticsStateHolder { + private val _state = MutableStateFlow(PeerConnectionAnalyticsState()) + val state: StateFlow = _state.asStateFlow() + + fun updatePeerConnectionObserverJob(job: Job?) { + _state.update { it.copy(peerConnectionObserverJob = job) } + } + + fun updatePublisherJob(job: Job?) { + _state.update { it.copy(publisherJob = job) } + } + + fun updateSubscriberJob(job: Job?) { + _state.update { it.copy(subscriberJob = job) } + } + + fun updatePublisherStage(stage: Stage) { + _state.update { it.copy(publisherStage = stage) } + } + + fun updateSubscriberStage(stage: Stage) { + _state.update { it.copy(subscriberStage = stage) } + } + + fun update( + peerConnectionObserverJob: Job? = state.value.peerConnectionObserverJob, + publisherJob: Job? = state.value.publisherJob, + subscriberJob: Job? = state.value.subscriberJob, + publisherStage: Stage = state.value.publisherStage, + subscriberStage: Stage = state.value.subscriberStage, + ) { + _state.update { + it.copy( + peerConnectionObserverJob = peerConnectionObserverJob, + publisherJob = publisherJob, + subscriberJob = subscriberJob, + publisherStage = publisherStage, + subscriberStage = subscriberStage, + ) + } + } + + fun reset() { + _state.value = PeerConnectionAnalyticsState() + } +} + +internal data class PeerConnectionAnalyticsState( + val peerConnectionObserverJob: Job? = null, + val publisherJob: Job? = null, + val subscriberJob: Job? = null, + val publisherStage: Stage = Stage.NOT_STARTED, + val subscriberStage: Stage = Stage.NOT_STARTED, +) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalytics.kt new file mode 100644 index 00000000000..73f63ff7f05 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalytics.kt @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.android.video.generated.models.ClientEvent +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.analytics.reporting.dispatcher.EventDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class SfuAnalytics( + val isEnabled: Boolean, + val callId: String, + val callType: String, + val connectionFlow: StateFlow, + val observerScope: CoroutineScope, + val reporter: ClientEventReporter, + val joinAnalyticsStateHolder: JoinAnalyticsStateHolder, + val sfuAnalyticsStateHolder: SfuAnalyticsStateHolder, +) { + + companion object { + internal fun getFakeSfuAnalytics(): SfuAnalytics { + return SfuAnalytics( + false, + "", + "", + MutableStateFlow( + RealtimeConnection.PreJoin, + ), + CoroutineScope(Dispatchers.Default), + ClientEventReporter( + sender = object : + EventDispatcher { + override fun send(event: ClientEvent) {} + + override fun sendAll(events: List) {} + }, + userAgent = { "" }, + sdkVersion = "", + ), + JoinAnalyticsStateHolder(), + SfuAnalyticsStateHolder(), + ) + } + } + + fun onSfuWsInitiated(wasPreviouslyConnected: Boolean) { + if (!isEnabled) return + when (sfuAnalyticsStateHolder.stage.value) { + Stage.NOT_STARTED, Stage.COMPLETED -> { + val wsEventStageId = reporter.reportSfuWsJoinInitiated( + callId = callId, + callType = callType, + sfuId = sfuAnalyticsStateHolder.sfuId.value, + wasPreviouslyConnected = wasPreviouslyConnected, + joinStageAttemptId = joinAnalyticsStateHolder.state.value.joinStageAttemptId + ?: "unknown", + joinReason = joinAnalyticsStateHolder.state.value.joinReason + ?: JoinReason.Unknown, + callSessionId = joinAnalyticsStateHolder.state.value.callSessionId, + ) + sfuAnalyticsStateHolder.updateStage(Stage.IN_PROGRESS) + sfuAnalyticsStateHolder.updateStageId(wsEventStageId) + } else -> {} + } + } + + fun onSfuWsCompleted( + success: Boolean, + retryCount: Int, + failureReason: String? = null, + failureCode: String? = null, + ) { + if (!isEnabled) return + if (sfuAnalyticsStateHolder.stage.value == Stage.IN_PROGRESS) { + if (sfuAnalyticsStateHolder.stageId.value.isNotEmpty()) { + reporter.reportSfuWsJoinCompleted( + stageId = sfuAnalyticsStateHolder.stageId.value, + success = success, + retryCount = retryCount, + failureReason = failureReason, + failureCode = failureCode, + joinStageAttemptId = joinAnalyticsStateHolder.state.value.joinStageAttemptId ?: "unknown", + ) + } + sfuAnalyticsStateHolder.updateStage(Stage.COMPLETED) + } + } + + fun resetStage() { + sfuAnalyticsStateHolder.updateStage(Stage.NOT_STARTED) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalyticsStateHolder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalyticsStateHolder.kt new file mode 100644 index 00000000000..ce373c55144 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalyticsStateHolder.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal class SfuAnalyticsStateHolder { + private val _sfuId = MutableStateFlow("") + val sfuId: StateFlow = _sfuId.asStateFlow() + + private val _stage = MutableStateFlow(Stage.NOT_STARTED) + val stage: StateFlow = _stage.asStateFlow() + + private val _stageId = MutableStateFlow("") + val stageId: StateFlow = _stageId.asStateFlow() + + fun updateSfuId(sfuId: String) { + _sfuId.value = sfuId + } + + fun updateStage(stage: Stage) { + _stage.value = stage + } + + fun updateStageId(eventStageId: String) { + _stageId.value = eventStageId + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/VideoAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/VideoAnalytics.kt new file mode 100644 index 00000000000..34e0ebbb6b5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/VideoAnalytics.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.ParticipantState +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.call.RtcSession +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import stream.video.sfu.models.TrackType + +internal class VideoAnalytics( + private val callId: String, + private val callType: String, + private val myParticipantState: StateFlow, + private val clientEventReporter: ClientEventReporter, + private val joinAnalyticsStateHolder: JoinAnalyticsStateHolder, + private val sfuAnalyticsStateHolder: SfuAnalyticsStateHolder, +) { + + val stageId = MutableStateFlow("") + + fun firstVideoFrameRendered( + trackType: TrackType, + width: Int, + height: Int, + rtcSession: RtcSession?, + videoSessionId: String, + callSessionId: String, + ) { + // early return + if (!stageId.value.isEmpty()) return + + when (trackType) { + TrackType.TRACK_TYPE_VIDEO, TrackType.TRACK_TYPE_SCREEN_SHARE -> { + val isSelf = videoSessionId == myParticipantState.value?.sessionId + if (isSelf) return + + val videoTrackId = rtcSession?.subscriber?.value?.getTrack( + videoSessionId, + trackType, + )?.asVideoTrack()?.video?.id() + + videoTrackId?.let { + if (stageId.value.isEmpty()) { + stageId.value = clientEventReporter.reportFirstRemoteVideoFrameRendered( + sfuAnalyticsStateHolder.sfuId.value, + callId, + callType, + joinStageAttemptId = joinAnalyticsStateHolder.state.value.joinStageAttemptId + ?: "unknown", + joinReason = joinAnalyticsStateHolder.state.value.joinReason + ?: JoinReason.Unknown, + trackId = videoTrackId, + callSessionId = joinAnalyticsStateHolder.state.value.callSessionId, + + ) + } + } + } + + else -> {} + } + } + + fun reset() { + stageId.value = "" + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/model/JoinAnalyticsModel.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/model/JoinAnalyticsModel.kt new file mode 100644 index 00000000000..3508a905739 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/model/JoinAnalyticsModel.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer.model + +internal data class JoinAnalyticsModel(val retryAttempt: Int, val joinReason: JoinReason? = null) +internal sealed class JoinReason { + + abstract val message: String + + data object FirstAttempt : JoinReason() { + override val message: String = "first-attempt" + } + + data object ReJoin : JoinReason() { + override val message: String = "full-rejoin" + } + + data object Migrate : JoinReason() { + override val message: String = "migrate" + } + + data object Unknown : JoinReason() { + override val message: String = "unknown" + } + + data class Custom( + override val message: String, + ) : JoinReason() +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/model/Stage.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/model/Stage.kt new file mode 100644 index 00000000000..29b67921aa5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/model/Stage.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer.model + +internal enum class Stage { NOT_STARTED, IN_PROGRESS, COMPLETED } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/coordinator/CoordinatorAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/coordinator/CoordinatorAnalytics.kt new file mode 100644 index 00000000000..2aa274581d5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/coordinator/CoordinatorAnalytics.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.coordinator + +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.socket.coordinator.CoordinatorSocketStateService +import io.getstream.video.android.core.socket.coordinator.state.VideoSocketConnectionType +import io.getstream.video.android.core.socket.coordinator.state.VideoSocketState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +internal class CoordinatorAnalytics( + private val observerScope: CoroutineScope, + private val eventReporter: ClientEventReporter, +) { + + private var job: Job? = null + private var stageId = MutableStateFlow("") + + internal fun startObserver(videoSocketStateFlow: StateFlow) { + endObserver() + job = observerScope.launch { + videoSocketStateFlow.collect { + // because demo-app invokes coordinator join before user id is ready + val userIdIsNotNull = StreamVideo.instanceOrNull()?.userId != null + if (userIdIsNotNull) { + when (it) { + is VideoSocketState.Connecting -> { + when (it.connectionType) { + VideoSocketConnectionType.INITIAL_CONNECTION -> { + stageId.value = eventReporter.reportCoordinatorWSInitiated() + } + + else -> {} + } + } + + is VideoSocketState.Connected -> { + if (stageId.value.isNotEmpty()) { + eventReporter.reportCoordinatorWSCompleted( + stageId.value, + true, + CoordinatorSocketStateService.Companion.lastRetryAttempts, + ) + stageId.value = "" + } + } + + is VideoSocketState.Disconnected.DisconnectedPermanently -> { + if (stageId.value.isNotEmpty()) { + eventReporter.reportCoordinatorWSCompleted( + stageId.value, + false, + CoordinatorSocketStateService.Companion.lastRetryAttempts, + ) + stageId.value = "" + } + } + + else -> {} + } + } + } + } + } + + fun endObserver() { + job?.cancel() + job = null + stageId.value = "" + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventFactory.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventFactory.kt new file mode 100644 index 00000000000..0396d3c74f9 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventFactory.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting + +import io.getstream.android.video.generated.models.ClientEvent +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.reporting.model.EventOutcome +import io.getstream.video.android.core.analytics.reporting.model.EventStage +import io.getstream.video.android.core.analytics.reporting.model.EventType +import io.getstream.video.android.core.analytics.reporting.model.PeerConnectionRole +import org.threeten.bp.OffsetDateTime +import org.threeten.bp.ZoneOffset +import org.webrtc.PeerConnection + +internal class ClientEventFactory(val sdkVersion: String, val userAgent: () -> String, val getCoordinatorId: () -> String) { + + fun buildRequest( + callId: String? = null, + callType: String? = null, + stage: EventStage, + eventType: EventType, + stageId: String? = null, + joinReason: JoinReason? = null, + joinStageAttemptId: String? = null, + elapsedTime: Long? = null, + outcome: EventOutcome? = null, + retryCountAttempt: Int? = null, + retryFailureReason: String? = null, + retryFailureCode: String? = null, + callSessionId: String? = null, + sfuId: String? = null, + peerConnection: PeerConnectionRole? = null, + wasPreviouslyConnected: Boolean? = null, + iceState: PeerConnection.IceConnectionState? = null, + peerConnectionState: PeerConnection.PeerConnectionState? = null, + userSessionId: String? = null, + screenShareAllowed: Boolean? = null, + microphoneAllowed: Boolean? = null, + cameraAllowed: Boolean? = null, + trackId: String? = null, + ): ClientEvent = ClientEvent( + stageId = stageId, + joinAttemptId = joinStageAttemptId, + eventType = eventType.value, + id = callId, + sdkVersion = sdkVersion, + stage = stage.value, + timestamp = OffsetDateTime.now(ZoneOffset.UTC), + type = callType, + userAgent = userAgent.invoke().take(512), + userId = StreamVideo.Companion.instanceOrNull()?.userId, + callSessionId = callSessionId, + elapsedTime = elapsedTime?.toInt(), + iceState = iceState?.name, + outcome = outcome?.value, + peerConnection = peerConnection?.value, + previouslyConnectedTimestamp = null, + retryCountAttempt = retryCountAttempt, + retryFailureCode = retryFailureCode, + retryFailureReason = retryFailureReason, + sfuId = sfuId, + userSessionId = userSessionId, + wasPreviouslyConnected = wasPreviouslyConnected, + screenShareStatus = getPermissionStatusText(screenShareAllowed), + microphonePermissionStatus = getPermissionStatusText(microphoneAllowed), + cameraPermissionStatus = getPermissionStatusText(cameraAllowed), + trackId = trackId, + coordinatorConnectId = getCoordinatorId(), + joinReason = joinReason?.message, + ) + + fun getPermissionStatusText(allowed: Boolean?): String? { + return if (allowed == true) "GRANTED" else if (allowed == false) "FAILED" else null + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventReporter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventReporter.kt new file mode 100644 index 00000000000..bd82586114a --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventReporter.kt @@ -0,0 +1,634 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting + +import android.util.Log +import io.getstream.android.video.generated.apis.ProductvideoApi +import io.getstream.android.video.generated.models.ClientEvent +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.BuildConfig +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.reporting.dispatcher.EventDispatcher +import io.getstream.video.android.core.analytics.reporting.dispatcher.ImmediateEventDispatcher +import io.getstream.video.android.core.analytics.reporting.model.CoordinatorFlightSession +import io.getstream.video.android.core.analytics.reporting.model.EventOutcome +import io.getstream.video.android.core.analytics.reporting.model.EventStage +import io.getstream.video.android.core.analytics.reporting.model.EventType +import io.getstream.video.android.core.analytics.reporting.model.InFlightSession +import io.getstream.video.android.core.analytics.reporting.model.PeerConnectionRole +import io.getstream.video.android.core.analytics.reporting.model.PostCallFlightSession +import io.getstream.video.android.core.analytics.reporting.model.StageId +import io.getstream.video.android.core.header.HeadersUtil +import io.getstream.video.android.core.socket.common.scope.UserScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import org.webrtc.PeerConnection +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +internal class ClientEventReporter( + private val sender: EventDispatcher, + private val userAgent: () -> String, + private val sdkVersion: String, +) { + + companion object { + + fun getDefault( + api: ProductvideoApi, + ): ClientEventReporter { + return ClientEventReporter( + sender = ImmediateEventDispatcher( + api = api, + scope = CoroutineScope( + SupervisorJob(UserScope().coroutineContext[Job]) + + Dispatchers.IO + + CoroutineExceptionHandler(handler = { _, throwable -> + Log.e("ClientEvent", "Error in ClientEventReporter: $throwable") + }), + ), + ), + userAgent = { HeadersUtil().buildSdkTrackingHeaders() }, + sdkVersion = BuildConfig.STREAM_VIDEO_VERSION, + ) + } + } + + private val logger by taggedLogger("ClientEventReporter") + + private val clientEventFactory = ClientEventFactory(sdkVersion, userAgent) { + this.coordinatorConnectId + } + + private val postCallFlightSessions = ConcurrentHashMap() + private val pcEverConnected = ConcurrentHashMap() + private val pcEventReporterStateHolder = PeerConnectionEventReporterStateHolder() + + @Volatile + private var coordinatorConnectId = "" + + // --- Coordinator WS --- + internal fun reportCoordinatorWSInitiated(): String { + this.coordinatorConnectId = UUID.randomUUID().toString() + val stageId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + postCallFlightSessions[stageId] = CoordinatorFlightSession( + stageId = stageId, + coordinatorConnectId = coordinatorConnectId, + stage = EventStage.CoordinatorWs, + startedAtMs = now, + ) + sender.send( + clientEventFactory.buildRequest( + stageId = stageId, + stage = EventStage.CoordinatorWs, + eventType = EventType.INITIATED, + ), + ) + return stageId + } + + internal fun reportCoordinatorWSCompleted( + stageId: String, + success: Boolean, + retryCount: Int? = null, + failureCode: String? = null, + failureReason: String? = null, + ) { + val session = postCallFlightSessions.remove(stageId) ?: return + if (session is CoordinatorFlightSession) { + val elapsedTime = System.currentTimeMillis() - session.startedAtMs + sender.send( + clientEventFactory.buildRequest( + stage = EventStage.CoordinatorWs, + stageId = stageId, + outcome = if (success) EventOutcome.SUCCESS else EventOutcome.FAILURE, + retryCountAttempt = retryCount, + retryFailureCode = failureCode, + elapsedTime = elapsedTime, + retryFailureReason = failureReason, + eventType = EventType.COMPLETED, + ), + ) + } + } + + internal fun reportSdkMethodJoinInitiated( + callId: String, + callType: String, + joinStageAttemptId: String, + ) { + sender.send( + clientEventFactory.buildRequest( + callId, + callType, + stage = EventStage.Call.JOIN_INITIATED, + eventType = EventType.INITIATED, + joinStageAttemptId = joinStageAttemptId, + ), + ) + } + + // --- Coordinator Join --- + + internal fun reportCoordinatorJoinInitiated( + callId: String, + callType: String, + joinStageAttemptId: String, + joinReason: JoinReason, + ): String { + val stageId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + postCallFlightSessions[stageId] = PostCallFlightSession( + stageId = stageId, + callId = callId, + callType = callType, + stage = EventStage.Call.COORDINATOR_JOIN, + startedAtMs = now, + joinStageAttemptIdSnapshot = joinStageAttemptId, + joinReason = joinReason, + ) + sender.send( + clientEventFactory.buildRequest( + callId, + callType, + stage = EventStage.Call.COORDINATOR_JOIN, + eventType = EventType.INITIATED, + stageId = stageId, + joinStageAttemptId = joinStageAttemptId, + joinReason = joinReason, + ), + ) + return stageId + } + + internal fun reportCoordinatorJoinCompleted( + stageId: String, + success: Boolean, + retryCount: Int, + failureReason: String? = null, + failureCode: String? = null, + callSessionId: String? = null, + ) = completePostCall(stageId) { session, elapsedTime -> + clientEventFactory.buildRequest( + callId = session.callId, + callType = session.callType, + stage = EventStage.Call.COORDINATOR_JOIN, + eventType = EventType.COMPLETED, + stageId = stageId, + elapsedTime = elapsedTime, + outcome = if (success) EventOutcome.SUCCESS else EventOutcome.FAILURE, + retryCountAttempt = retryCount, + retryFailureReason = if (!success) failureReason else null, + retryFailureCode = if (!success) failureCode else null, + callSessionId = callSessionId, + joinStageAttemptId = session.joinStageAttemptIdSnapshot, + joinReason = session.joinReason, + + ) + } + + // --- SFU Join --- + + internal fun reportSfuWsJoinInitiated( + sfuId: String, + callId: String, + callType: String, + joinStageAttemptId: String, + callSessionId: String, + joinReason: JoinReason, + wasPreviouslyConnected: Boolean, + ): String { + val stageId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + postCallFlightSessions[stageId] = PostCallFlightSession( + callId = callId, + callType = callType, + stageId = stageId, + stage = EventStage.Call.WS_JOIN, + startedAtMs = now, + joinStageAttemptIdSnapshot = joinStageAttemptId, + sfuId = sfuId, + wasPreviouslyConnected = wasPreviouslyConnected, + callSessionId = callSessionId, + joinReason = joinReason, + ) + sender.send( + clientEventFactory.buildRequest( + callId = callId, + callType = callType, + stage = EventStage.Call.WS_JOIN, + eventType = EventType.INITIATED, + stageId = stageId, + joinStageAttemptId = joinStageAttemptId, + sfuId = sfuId, + wasPreviouslyConnected = wasPreviouslyConnected, + callSessionId = callSessionId, + joinReason = joinReason, + ), + ) + return stageId + } + + internal fun reportSfuWsJoinCompleted( + stageId: String, + joinStageAttemptId: String, + success: Boolean, + retryCount: Int, + failureReason: String? = null, + failureCode: String? = null, + ) = completePostCall(stageId) { session, elapsedTime -> + clientEventFactory.buildRequest( + callId = session.callId, + callType = session.callType, + stage = EventStage.Call.WS_JOIN, + eventType = EventType.COMPLETED, + stageId = stageId, + elapsedTime = elapsedTime, + outcome = if (success) EventOutcome.SUCCESS else EventOutcome.FAILURE, + retryCountAttempt = retryCount, + retryFailureReason = if (!success) failureReason else null, + retryFailureCode = if (!success) failureCode else null, + sfuId = session.sfuId, + callSessionId = session.callSessionId, + joinStageAttemptId = joinStageAttemptId, + joinReason = session.joinReason, + ) + } + + // --- PeerConnectionConnect (ICE state machine) --- + + internal fun onPeerConnectionStateChanged( + peerConnectionHashCode: Int, + callId: String, + callType: String, + joinStageAttemptId: String, + callSessionId: String, + sfuId: String, + joinReason: JoinReason, + role: PeerConnectionRole, + iceState: PeerConnection.IceConnectionState?, + peerConnectionState: PeerConnection.PeerConnectionState?, + ) { + when (peerConnectionState) { + PeerConnection.PeerConnectionState.CONNECTING -> { + handleOnPeerConnectionConnectingState( + peerConnectionHashCode, + callId, + callType, + joinStageAttemptId, + callSessionId, + sfuId, + joinReason, + role, + iceState, + peerConnectionState, + ) + } + PeerConnection.PeerConnectionState.CONNECTED -> { + iceState?.let { + handleOnPeerConnectionConnectedState( + peerConnectionHashCode, + callId, + callType, + joinStageAttemptId, + callSessionId, + sfuId, + joinReason, + role, + iceState, + peerConnectionState, + ) + } + } + PeerConnection.PeerConnectionState.FAILED -> { + iceState?.let { + handleOnPeerConnectionFailedState( + peerConnectionHashCode, + callId, + callType, + joinStageAttemptId, + callSessionId, + sfuId, + joinReason, + role, + iceState, + peerConnectionState, + ) + } + } + else -> {} + } + } + + fun handleOnPeerConnectionConnectingState( + peerConnectionHashCode: Int, + callId: String, + callType: String, + joinStageAttemptId: String, + callSessionId: String, + sfuId: String, + joinReason: JoinReason, + role: PeerConnectionRole, + iceState: PeerConnection.IceConnectionState?, + peerConnectionState: PeerConnection.PeerConnectionState, + ) { + val wasPrevConnected = pcEverConnected[role] != null + val stageId = UUID.randomUUID().toString() + val now = System.currentTimeMillis() + postCallFlightSessions[stageId] = PostCallFlightSession( + callId = callId, + callType = callType, + stageId = stageId, + stage = EventStage.Call.PEER_CONNECTION_CONNECT, + startedAtMs = now, + joinStageAttemptIdSnapshot = joinStageAttemptId, + peerConnectionRole = role, + wasPreviouslyConnected = wasPrevConnected, + callSessionId = callSessionId, + joinReason = joinReason, + sfuId = sfuId, + ) + pcEventReporterStateHolder.map[peerConnectionHashCode] = + PeerConnectionEventReporterState(stageId, role) + + sender.send( + clientEventFactory.buildRequest( + callId = callId, + callType = callType, + stage = EventStage.Call.PEER_CONNECTION_CONNECT, + eventType = EventType.INITIATED, + stageId = stageId, + sfuId = sfuId, + joinStageAttemptId = joinStageAttemptId, + peerConnection = role, + wasPreviouslyConnected = wasPrevConnected, + callSessionId = callSessionId, + iceState = iceState, + peerConnectionState = peerConnectionState, + joinReason = joinReason, + ), + ) + } + + fun handleOnPeerConnectionConnectedState( + peerConnectionHashCode: Int, + callId: String, + callType: String, + joinStageAttemptId: String, + callSessionId: String, + sfuId: String, + joinReason: JoinReason, + role: PeerConnectionRole, + iceState: PeerConnection.IceConnectionState, + peerConnectionState: + PeerConnection.PeerConnectionState, + ) { + pcEverConnected[role] = PcConnected(System.currentTimeMillis()) + val pcState = pcEventReporterStateHolder.map.remove(peerConnectionHashCode) ?: return + val stageId = pcState.stageId + + completePeerConnectionSession( + callId = callId, + callType = callType, + stageId = stageId, + joinStageAttemptId = joinStageAttemptId, + success = true, + iceState = iceState, + peerConnectionState = peerConnectionState, + joinReason = joinReason, + sfuId = sfuId, + callSessionId = callSessionId, + ) + } + + fun handleOnPeerConnectionFailedState( + peerConnectionHashCode: Int, + callId: String, + callType: String, + joinStageAttemptId: String, + callSessionId: String, + sfuId: String, + joinReason: JoinReason, + role: PeerConnectionRole, + iceState: PeerConnection.IceConnectionState, + peerConnectionState: PeerConnection.PeerConnectionState, + ) { + val pcState = pcEventReporterStateHolder.map.remove(peerConnectionHashCode) ?: return + val stageId = pcState.stageId + completePeerConnectionSession( + callId = callId, + callType = callType, + stageId = stageId, + joinStageAttemptId = joinStageAttemptId, + success = false, + iceState = iceState, + peerConnectionState = peerConnectionState, + failureReason = "ICE connectivity checks failed", + failureCode = "ICE_CONNECTIVITY_FAILED", + joinReason = joinReason, + sfuId = sfuId, + callSessionId = callSessionId, + ) + } + + private fun completePeerConnectionSession( + callId: String, + callType: String, + stageId: String, + sfuId: String, + joinStageAttemptId: String, + callSessionId: String, + joinReason: JoinReason, + success: Boolean, + iceState: PeerConnection.IceConnectionState, + peerConnectionState: PeerConnection.PeerConnectionState?, + failureReason: String? = null, + failureCode: String? = null, + ) = completePostCall(stageId) { session, elapsedTime -> + clientEventFactory.buildRequest( + callId = callId, + callSessionId = callSessionId, + callType = callType, + stage = EventStage.Call.PEER_CONNECTION_CONNECT, + eventType = EventType.COMPLETED, + stageId = stageId, + sfuId = sfuId, + joinStageAttemptId = joinStageAttemptId, + elapsedTime = elapsedTime, + outcome = if (success) EventOutcome.SUCCESS else EventOutcome.FAILURE, + retryCountAttempt = 0, + retryFailureReason = if (!success) failureReason else null, + retryFailureCode = if (!success) failureCode else null, + peerConnection = session.peerConnectionRole, + wasPreviouslyConnected = session.wasPreviouslyConnected, + iceState = iceState, + peerConnectionState = peerConnectionState, + joinReason = joinReason, + ) + } + + private inline fun completePostCall( + stageId: String, + build: (session: PostCallFlightSession, elapsedMs: Long) -> ClientEvent?, + ) { + val session = postCallFlightSessions.remove(stageId) ?: return + if (session !is PostCallFlightSession) return + val elapsedMs = System.currentTimeMillis() - session.startedAtMs + build(session, elapsedMs)?.let(sender::send) + } + + internal fun reportFirstAudioFrameRendered( + sfuId: String, + callId: String, + callType: String, + joinStageAttemptId: String, + callSessionId: String, + joinReason: JoinReason, + ): String { + val stageId = UUID.randomUUID().toString() + sender.send( + clientEventFactory.buildRequest( + callId = callId, + callType = callType, + stage = EventStage.Call.FIRST_AUDIO_FRAME_RENDERED, + eventType = EventType.INITIATED, + stageId = stageId, + joinStageAttemptId = joinStageAttemptId, + callSessionId = callSessionId, + sfuId = sfuId, + joinReason = joinReason, + ), + ) + return stageId + } + + internal fun reportFirstRemoteVideoFrameRendered( + sfuId: String, + callId: String, + callType: String, + joinStageAttemptId: String, + callSessionId: String, + joinReason: JoinReason, + trackId: String, + ): String { + val stageId = UUID.randomUUID().toString() + sender.send( + clientEventFactory.buildRequest( + callId = callId, + callType = callType, + stage = EventStage.Call.FIRST_VIDEO_FRAME_RENDERED, + eventType = EventType.INITIATED, + stageId = stageId, + joinStageAttemptId = joinStageAttemptId, + callSessionId = callSessionId, + sfuId = sfuId, + trackId = trackId, + joinReason = joinReason, + ), + ) + return stageId + } + + internal fun reportMediaPermissionStatus( + callId: String, + callType: String, + joinStageAttemptId: String, + joinReason: JoinReason, + isCameraGranted: Boolean, + isMicrophoneGranted: Boolean, + ): String { + val stageId = UUID.randomUUID().toString() + sender.send( + clientEventFactory.buildRequest( + callId = callId, + callType = callType, + stage = EventStage.Call.MEDIA_DEVICE_PERMISSION, + eventType = EventType.INITIATED, + stageId = stageId, + joinStageAttemptId = joinStageAttemptId, + cameraAllowed = isCameraGranted, + microphoneAllowed = isMicrophoneGranted, + joinReason = joinReason, + ), + ) + return stageId + } + + internal fun abortAllPostCallInFlight( + publisherIceState: PeerConnection.IceConnectionState?, + subscriberIceState: PeerConnection.IceConnectionState?, + failCode: String, + failMessage: String, + ) { + val snapshot = mutableListOf() + postCallFlightSessions.entries.forEach { (stageId, session) -> + if (session is PostCallFlightSession && postCallFlightSessions.remove(stageId, session)) { + snapshot += session + } + } + val now = System.currentTimeMillis() + val events = snapshot.map { session -> + val iceState = when (session.peerConnectionRole) { + PeerConnectionRole.PUBLISH -> { + publisherIceState + } + + PeerConnectionRole.SUBSCRIBE -> { + subscriberIceState + } + + else -> null + } + + clientEventFactory.buildRequest( + callId = session.callId, + callType = session.callType, + stage = session.stage, + eventType = EventType.COMPLETED, + stageId = session.stageId, + elapsedTime = now - session.startedAtMs, + outcome = EventOutcome.FAILURE, + retryCountAttempt = 0, + retryFailureReason = failMessage, + retryFailureCode = failCode, + sfuId = session.sfuId, + callSessionId = session.callSessionId, + peerConnection = session.peerConnectionRole, + iceState = iceState, + wasPreviouslyConnected = session.wasPreviouslyConnected, + userSessionId = session.userSessionId, + joinStageAttemptId = session.joinStageAttemptIdSnapshot, + joinReason = session.joinReason, + ) + } + sender.sendAll(events) + } +} + +internal typealias ObjectHashCode = Int +internal class PeerConnectionEventReporterStateHolder { + val map = ConcurrentHashMap() +} +internal class PeerConnectionEventReporterState( + var stageId: String, + val peerConnectionRole: PeerConnectionRole, +) +internal class PcConnected(val lastConnectedTime: Long) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/EventDispatcher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/EventDispatcher.kt new file mode 100644 index 00000000000..b3c64a2e333 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/EventDispatcher.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.dispatcher + +import io.getstream.android.video.generated.models.ClientEvent + +/** + * Responsible for delivering telemetry [ClientEvent]s to the backend. + */ +internal interface EventDispatcher { + fun send(event: ClientEvent) + fun sendAll(events: List) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/ImmediateEventDispatcher.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/ImmediateEventDispatcher.kt new file mode 100644 index 00000000000..d7c11b3bd76 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/ImmediateEventDispatcher.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.dispatcher + +import io.getstream.android.video.generated.apis.ProductvideoApi +import io.getstream.android.video.generated.models.ClientEvent +import io.getstream.android.video.generated.models.ReportClientEventRequest +import io.getstream.log.taggedLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import retrofit2.HttpException +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +/** + * Sends each event immediately in a coroutine. + */ +internal class ImmediateEventDispatcher( + private val api: ProductvideoApi, + private val scope: CoroutineScope, +) : EventDispatcher { + + private companion object { + const val RETRY_MAX_ATTEMPT = 5 + const val BASE_RETRY_DELAY_MS = 500L + } + + private val logger by taggedLogger("ImmediateEventSender") + + override fun send(event: ClientEvent) = sendAll(listOf(event)) + + override fun sendAll(events: List) { + if (events.isEmpty()) return + scope.launch { + retryWithBackoff { + logger.d { events.joinToString(",") { it.toLog() } } + api.reportClientCallEvent(ReportClientEventRequest(events)) + }.onFailure { e -> + logger.w { "[sendAll] Failed — sending ${events.size} event(s) for retry: ${e.message}" } + logger.w { events.joinToString(",") { it.toLog() } } + } + } + } + + suspend fun retryWithBackoff(maxAttempts: Int = RETRY_MAX_ATTEMPT, lambda: suspend () -> Unit): Result { + val baseDelayMs = BASE_RETRY_DELAY_MS + + repeat(maxAttempts) { attempt -> + + val result = runCatching { lambda() } + + if (result.isSuccess) { + return result + } + + val exception = result.exceptionOrNull() + + if (!shouldRetry(exception)) { + return Result.failure(NonRetryableException(exception)) + } + + if (attempt < maxAttempts - 1) { + val delayMs = baseDelayMs * (1L shl attempt) // 500, 1000, 2000, 4000, 8000 + delay(delayMs) + } + } + + return Result.failure(RetryExhaustedException(null)) + } + + fun shouldRetry(throwable: Throwable?): Boolean { + return when (throwable) { + is HttpException -> { + throwable.code() in 500..599 + } + + is SocketTimeoutException, + is ConnectException, + is UnknownHostException, + is IOException, + -> { + true + } + + else -> false + } + } +} + +private sealed class RetryException( + message: String? = null, + cause: Throwable? = null, +) : Exception(message, cause) + +private class NonRetryableException( + cause: Throwable?, +) : RetryException( + message = "Failure is not retryable", + cause = cause, +) + +private class RetryExhaustedException( + cause: Throwable?, +) : RetryException( + message = "Retry attempts exhausted", + cause = cause, +) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsCallAbortReason.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsCallAbortReason.kt new file mode 100644 index 00000000000..f3afb72d079 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsCallAbortReason.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.model + +internal enum class AnalyticsCallAbortReason(val code: String) { + CLIENT_ABORTED("CLIENT_ABORTED"), + BACKEND_LEAVE("BACKEND_LEAVE"), + NETWORK_OFFLINE("NETWORK_OFFLINE"), + RETRY_EXHAUSTED("RETRY_EXHAUSTED"), + SDK_DRIVEN_ANDROID("SDK_DRIVEN_ANDROID"), + REQUEST_TIMEOUT("REQUEST_TIMEOUT"), + SFU_ERROR("SFU_ERROR"), + SERVER_ERROR("SERVER_ERROR"), + CUSTOM("CUSTOM"), +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsFailureCodes.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsFailureCodes.kt new file mode 100644 index 00000000000..9195f3d4f15 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/AnalyticsFailureCodes.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.model + +// internal enum class AnalyticsFailureCodes(val code: String, val message: String) { +// SFU_REQUEST_TIMEOUT("REQUEST_TIMEOUT", "SFU connection timed out"), +// } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventOutcome.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventOutcome.kt new file mode 100644 index 00000000000..8f96f4b0301 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventOutcome.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.model + +internal enum class EventOutcome(val value: String) { + SUCCESS("success"), + FAILURE("failure"), +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventStage.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventStage.kt new file mode 100644 index 00000000000..dfb959dbb60 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventStage.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.model + +internal sealed interface EventStage { + val value: String + + data object CoordinatorWs : EventStage { + override val value = "CoordinatorWS" + } + + enum class Call(override val value: String) : EventStage { + JOIN_INITIATED("JoinInitiated"), + COORDINATOR_JOIN("CoordinatorJoin"), + WS_JOIN("WSJoin"), + PEER_CONNECTION_CONNECT("PeerConnectionConnect"), + FIRST_AUDIO_FRAME_RENDERED("FirstAudioFrame"), + FIRST_VIDEO_FRAME_RENDERED("FirstVideoFrame"), + MEDIA_DEVICE_PERMISSION("MediaDevicePermission"), + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventType.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventType.kt new file mode 100644 index 00000000000..5e262a9fea5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/EventType.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.model + +internal enum class EventType(val value: String) { + INITIATED("initiated"), + COMPLETED("completed"), +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/InFlightSession.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/InFlightSession.kt new file mode 100644 index 00000000000..3ff512c5a94 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/InFlightSession.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.model + +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason + +internal typealias StageId = String +internal typealias CallId = String + +internal sealed class InFlightSession( + open val startedAtMs: Long, + open val stageId: StageId, +) + +internal data class PostCallFlightSession( + val callId: String, + val callType: String, + override val stageId: StageId, + val stage: EventStage.Call, + override val startedAtMs: Long, + val joinStageAttemptIdSnapshot: String, + val joinReason: JoinReason, + val sfuId: String? = null, + val callSessionId: String? = null, + val userSessionId: String? = null, + val peerConnectionRole: PeerConnectionRole? = null, + val wasPreviouslyConnected: Boolean = false, + +) : InFlightSession(startedAtMs, stageId) + +internal data class CoordinatorFlightSession( + val stage: EventStage.CoordinatorWs, + override val startedAtMs: Long, + override val stageId: StageId, + val coordinatorConnectId: String, +) : InFlightSession(startedAtMs, stageId) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/PeerConnectionRole.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/PeerConnectionRole.kt new file mode 100644 index 00000000000..f955d072da9 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/reporting/model/PeerConnectionRole.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.model + +internal enum class PeerConnectionRole(val value: String) { + PUBLISH("publish"), + SUBSCRIBE("subscribe"), +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt index eb5602d9d1d..a45b1389c6c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/RtcSession.kt @@ -45,6 +45,9 @@ import io.getstream.video.android.core.MediaManagerImpl import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.analytics.call.observer.SfuAnalytics +import io.getstream.video.android.core.analytics.call.observer.model.JoinAnalyticsModel +import io.getstream.video.android.core.analytics.reporting.model.AnalyticsCallAbortReason import io.getstream.video.android.core.call.connection.Publisher import io.getstream.video.android.core.call.connection.StreamPeerConnection import io.getstream.video.android.core.call.connection.Subscriber @@ -258,6 +261,7 @@ public class RtcSession internal constructor( ).replace("/twirp", "") }", ), + private val sfuAnalytics: SfuAnalytics, private val sfuConnectionModuleProvider: () -> SfuConnectionModule = { SfuConnectionModule( context = clientImpl.context, @@ -292,6 +296,7 @@ public class RtcSession internal constructor( }, tracer = sfuTracer, tokenRepository = TokenRepository(sfuToken), + sfuAnalytics = sfuAnalytics, ) }, ) { @@ -694,6 +699,10 @@ public class RtcSession internal constructor( pendingTrickleEvents.forEach { sendIceCandidate(it.first, it.second) } + call.callAnalytics.sfuAnalytics.onSfuWsCompleted( + success = true, + retryCount = 0, + ) } is SfuSocketState.Connecting -> { @@ -878,6 +887,7 @@ public class RtcSession internal constructor( internal suspend fun connectInternal( reconnectDetails: ReconnectDetails? = null, options: List? = null, + joinAnalyticsModel: JoinAnalyticsModel? = null, ): SfuConnectionResult { logger.i { "[connectInternal] #sfu; #track; reconnect=${reconnectDetails?.strategy}" } val request = buildJoinRequest(reconnectDetails, options) @@ -910,11 +920,32 @@ public class RtcSession internal constructor( logger.w { "[connectInternal] $msg" } sfuTracer.trace("connect-failed", msg) sendCallStats() + when (terminalState) { + is SfuSocketState.Disconnected.DisconnectedTemporarily, + is SfuSocketState.Disconnected.DisconnectedPermanently, + -> { + call.callAnalytics.sfuAnalytics.onSfuWsCompleted( + success = false, + retryCount = joinAnalyticsModel?.retryAttempt ?: 0, + failureReason = msg, + failureCode = AnalyticsCallAbortReason.SFU_ERROR.name, + ) + } + + else -> {} + } + SfuConnectionResult.Failed(Exception(msg)) } else -> { sfuTracer.trace("connect-failed", "Connection timed out") sendCallStats() + call.callAnalytics.sfuAnalytics.onSfuWsCompleted( + success = false, + retryCount = joinAnalyticsModel?.retryAttempt ?: 0, + failureReason = "SFU connection timed out", + failureCode = AnalyticsCallAbortReason.REQUEST_TIMEOUT.name, + ) SfuConnectionResult.Failed(Exception("SFU connection timed out")) } } @@ -1889,7 +1920,7 @@ public class RtcSession internal constructor( return Triple(previousSessionId, currentSubscriptions, publisherTracks) } - internal suspend fun fastReconnect(reconnectDetails: ReconnectDetails?): FastReconnectResult { + internal suspend fun fastReconnect(reconnectDetails: ReconnectDetails?, joinAnalyticsModel: JoinAnalyticsModel? = null): FastReconnectResult { logger.d { "[fastReconnect] Starting fast reconnect." } sfuTracer.trace("fastReconnect", reconnectDetails.toString()) val (_, _, publisherTracks) = currentSfuInfo() @@ -1898,6 +1929,7 @@ public class RtcSession internal constructor( val connectResult = connectInternal( reconnectDetails, publisher.value?.currentOptions(), + joinAnalyticsModel, ) if (connectResult is SfuConnectionResult.Failed) { return FastReconnectResult.Failed(connectResult.error) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/SfuConnectionModule.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/SfuConnectionModule.kt index 1debdbb3cfd..be2c51ee1e4 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/SfuConnectionModule.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/SfuConnectionModule.kt @@ -19,6 +19,7 @@ package io.getstream.video.android.core.internal.module import android.content.Context import android.net.ConnectivityManager import androidx.lifecycle.Lifecycle +import io.getstream.video.android.core.analytics.call.observer.SfuAnalytics import io.getstream.video.android.core.api.SignalServerService import io.getstream.video.android.core.call.utils.RetryableSignalingServiceDecorator import io.getstream.video.android.core.internal.network.NetworkStateProvider @@ -39,6 +40,7 @@ import java.util.concurrent.TimeUnit internal class SfuConnectionModule( context: Context, val tokenRepository: TokenRepository, + val sfuAnalytics: SfuAnalytics, override val apiKey: ApiKey, override val apiUrl: String, override val wssUrl: String, @@ -96,6 +98,7 @@ internal class SfuConnectionModule( lifecycle = lifecycle, networkStateProvider = networkStateProvider, tokenRepository = tokenRepository, + sfuAnalytics = sfuAnalytics, ) override val socketConnection: SfuSocketConnection = _internalSocketConnection diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketStateService.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketStateService.kt index 0e81094d90b..48641e9d976 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketStateService.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketStateService.kt @@ -30,6 +30,12 @@ import kotlinx.coroutines.flow.StateFlow internal class CoordinatorSocketStateService(initialState: VideoSocketState = VideoSocketState.Disconnected.Stopped) { private val logger by taggedLogger("Video:SocketState") + // TODO Pass it via CoordinatorSocketConnection internal's constructor + internal companion object { + var retryAttempts = 0 + var lastRetryAttempts = 0 + } + suspend fun observer(onNewState: suspend (VideoSocketState) -> Unit) { stateMachine.stateFlow.collect(onNewState) } @@ -43,6 +49,7 @@ internal class CoordinatorSocketStateService(initialState: VideoSocketState = Vi connectionConf: ConnectionConf, forceReconnection: Boolean, ) { + retryAttempts += 1 logger.v { "[onReconnect] user.id: '${connectionConf.user.id}', isReconnection: ${connectionConf.isReconnection}" } @@ -63,6 +70,8 @@ internal class CoordinatorSocketStateService(initialState: VideoSocketState = Vi * @param connectionConf The [VideoSocketFactory.ConnectionConf] to be used on the new connection. */ suspend fun onConnect(connectionConf: ConnectionConf) { + lastRetryAttempts = retryAttempts + retryAttempts = 0 logger.v { "[onConnect] user.id: '${connectionConf.user.id}', isReconnection: ${connectionConf.isReconnection}" } @@ -88,6 +97,8 @@ internal class CoordinatorSocketStateService(initialState: VideoSocketState = Vi * @param connectedEvent The [ConnectedEvent] received within the WebSocket connection. */ suspend fun onConnectionEstablished(connectedEvent: ConnectedEvent) { + lastRetryAttempts = retryAttempts + retryAttempts = 0 logger.i { "[onConnected] user.id: '${connectedEvent.me.id}', connectionId: ${connectedEvent.connectionId}" } @@ -100,6 +111,8 @@ internal class CoordinatorSocketStateService(initialState: VideoSocketState = Vi * @param error The [Error.NetworkError] */ suspend fun onUnrecoverableError(error: Error.NetworkError) { + lastRetryAttempts = retryAttempts + retryAttempts = 0 logger.e { "[onUnrecoverableError] error: $error" } stateMachine.sendEvent(VideoSocketStateEvent.UnrecoverableError(error)) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt index afeb22012b7..28b7a0e99f3 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt @@ -20,6 +20,7 @@ package io.getstream.video.android.core.socket.sfu import io.getstream.log.taggedLogger import io.getstream.result.Error +import io.getstream.video.android.core.analytics.call.observer.SfuAnalytics import io.getstream.video.android.core.dispatchers.DispatcherProvider import io.getstream.video.android.core.errors.DisconnectCause import io.getstream.video.android.core.errors.VideoErrorCode @@ -72,6 +73,7 @@ internal open class SfuSocket( private val userScope: UserScope, private val lifecycleObserver: StreamLifecycleObserver, private val networkStateProvider: NetworkStateProvider, + private val sfuAnalytics: SfuAnalytics, ) { private var streamWebSocket: StreamWebSocket? = null open val logger by taggedLogger(TAG) @@ -107,6 +109,9 @@ internal open class SfuSocket( streamWebSocket?.close("connectUser:cleanup") when (networkStateProvider.isConnected()) { true -> { + sfuAnalytics.onSfuWsInitiated( + wasPreviouslyConnected = connectionConf.joinRequest.reconnect_details != null, + ) streamWebSocket = socketFactory.createSocket(connectionConf, "#sfu").apply { listeners.forEach { it.onCreated() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt index 8b1316dac39..e17e854be9f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt @@ -18,6 +18,7 @@ package io.getstream.video.android.core.socket.sfu import androidx.lifecycle.Lifecycle import io.getstream.log.taggedLogger +import io.getstream.video.android.core.analytics.call.observer.SfuAnalytics import io.getstream.video.android.core.errors.DisconnectCause import io.getstream.video.android.core.events.JoinCallResponseEvent import io.getstream.video.android.core.events.SfuDataEvent @@ -50,7 +51,7 @@ import kotlinx.coroutines.launch import okhttp3.OkHttpClient import stream.video.sfu.event.JoinRequest -class SfuSocketConnection( +class SfuSocketConnection internal constructor( private val apiKey: ApiKey, /** The URL to connect to */ private val url: String, @@ -65,9 +66,34 @@ class SfuSocketConnection( /** Token provider */ private val tokenProvider: TokenProvider, private val tokenRepository: TokenRepository, + private val sfuAnalytics: SfuAnalytics, ) : SocketListener(), SocketActions { + @Deprecated( + message = "Pass a SfuAnalytics which is needed to trace sfu join events. ", + level = DeprecationLevel.WARNING, + ) + public constructor( + apiKey: ApiKey, + url: String, + httpClient: OkHttpClient, + networkStateProvider: NetworkStateProvider, + scope: CoroutineScope = UserScope(ClientScope()), + lifecycle: Lifecycle, + tokenProvider: TokenProvider, + tokenRepository: TokenRepository, + ) : this( + apiKey = apiKey, + url = url, + httpClient = httpClient, + networkStateProvider = networkStateProvider, + scope = scope, + lifecycle = lifecycle, + tokenProvider = tokenProvider, + tokenRepository = tokenRepository, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), + ) companion object { internal const val DEFAULT_SFU_SOCKET_TIMEOUT: Long = 10000L } @@ -85,6 +111,7 @@ class SfuSocketConnection( lifecycleObserver = StreamLifecycleObserver(scope, lifecycle), networkStateProvider = networkStateProvider, userScope = scope as? UserScope ?: UserScope(ClientScope()), + sfuAnalytics = sfuAnalytics, ).also { it.addListener(this) } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTest.kt index dc5f2e84fec..17cbcf7901d 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTest.kt @@ -27,6 +27,7 @@ import io.getstream.android.video.generated.models.MemberRequest import io.getstream.android.video.generated.models.ScreensharingSettingsRequest import io.getstream.android.video.generated.models.UserResponse import io.getstream.result.Result +import io.getstream.video.android.core.analytics.call.observer.model.JoinAnalyticsModel import io.getstream.video.android.core.base.IntegrationTestBase import io.getstream.video.android.core.events.DominantSpeakerChangedEvent import io.getstream.video.android.core.events.ParticipantCount @@ -109,6 +110,7 @@ class CallStateTest : IntegrationTestBase() { val response = call.joinRequest( create = CreateCallOptions(custom = mapOf("color" to "green")), location = "AMS", + joinAnalyticsModel = JoinAnalyticsModel(0), ) assertSuccess(response) assertThat(call.state.settings.value).isNotNull() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt index 3c84ce75102..4a391e89ba4 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt @@ -102,6 +102,7 @@ class StreamVideoClientTest { enableCallNotificationUpdates = false, sounds = sounds, vibrationConfig = vibration, + clientEventReporter = mockk(relaxed = true), ), recordPrivateCalls = true, ) @@ -430,6 +431,7 @@ class StreamVideoClientTest { enableCallNotificationUpdates = false, sounds = mockk(relaxed = true), vibrationConfig = mockk(relaxed = true), + clientEventReporter = mockk(relaxed = true), ) client.setupGuestUser(client.user) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/CallAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/CallAnalyticsTest.kt new file mode 100644 index 00000000000..8c2eb1fb51e --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/CallAnalyticsTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call + +import io.getstream.video.android.core.BackendCause +import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.ParticipantState +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.UserActionCause +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.analytics.reporting.model.AnalyticsCallAbortReason +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.connection.Publisher +import io.getstream.video.android.core.call.connection.Subscriber +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.webrtc.PeerConnection + +class CallAnalyticsTest { + + private val reporter = mockk(relaxed = true) + private val userLeave = CallLeaveReason.UserAction(UserActionCause.CANCELLED_BY_SELF) + private val backendLeave = CallLeaveReason.Backend(BackendCause.CALL_ENDED_EVENT) + private val noSession = MutableStateFlow(null) + + private fun callAnalytics(scope: CoroutineScope) = CallAnalytics( + context = mockk(relaxed = true), + callId = "call-1", + callType = "default", + myParticipantState = MutableStateFlow(null), + connectionFlow = MutableStateFlow(RealtimeConnection.PreJoin), + participants = MutableStateFlow>(emptyList()), + eventReporter = reporter, + observerScope = scope, + ) + + @Test + fun `onCallLeave without any stage in progress does not abort`() = runTest { + callAnalytics(backgroundScope).onCallLeave(noSession, userLeave) + + verify(exactly = 0) { reporter.abortAllPostCallInFlight(any(), any(), any(), any()) } + } + + @Test + fun `a client leave during an in-progress join aborts with CLIENT_ABORTED`() = runTest { + val analytics = callAnalytics(backgroundScope) + analytics.joinAnalyticsStateHolder.updateStage(Stage.IN_PROGRESS) + + analytics.onCallLeave(noSession, userLeave) + + verify(exactly = 1) { + reporter.abortAllPostCallInFlight( + any(), + any(), + AnalyticsCallAbortReason.CLIENT_ABORTED.name, + any(), + ) + } + } + + @Test + fun `a backend leave during an in-progress join aborts with BACKEND_LEAVE`() = runTest { + val analytics = callAnalytics(backgroundScope) + analytics.joinAnalyticsStateHolder.updateStage(Stage.IN_PROGRESS) + + analytics.onCallLeave(noSession, backendLeave) + + verify(exactly = 1) { + reporter.abortAllPostCallInFlight( + any(), + any(), + AnalyticsCallAbortReason.BACKEND_LEAVE.name, + any(), + ) + } + } + + @Test + fun `an in-progress sfu ws stage also triggers the abort`() = runTest { + val analytics = callAnalytics(backgroundScope) + analytics.sfuAnalyticsStateHolder.updateStage(Stage.IN_PROGRESS) + + analytics.onCallLeave(noSession, userLeave) + + verify(exactly = 1) { + reporter.abortAllPostCallInFlight( + any(), + any(), + AnalyticsCallAbortReason.CLIENT_ABORTED.name, + any(), + ) + } + } + + @Test + fun `an in-progress publisher peer connection also triggers the abort`() = runTest { + val analytics = callAnalytics(backgroundScope) + analytics.peerConnectionAnalytics.stateHolder.updatePublisherStage(Stage.IN_PROGRESS) + + analytics.onCallLeave(noSession, userLeave) + + verify(exactly = 1) { + reporter.abortAllPostCallInFlight( + any(), + any(), + AnalyticsCallAbortReason.CLIENT_ABORTED.name, + any(), + ) + } + } + + @Test + fun `an in-progress subscriber peer connection also triggers the abort`() = runTest { + val analytics = callAnalytics(backgroundScope) + analytics.peerConnectionAnalytics.stateHolder.updateSubscriberStage(Stage.IN_PROGRESS) + + analytics.onCallLeave(noSession, userLeave) + + verify(exactly = 1) { + reporter.abortAllPostCallInFlight( + any(), + any(), + AnalyticsCallAbortReason.CLIENT_ABORTED.name, + any(), + ) + } + } + + @Test + fun `an abort forwards the publisher and subscriber ice states from the session`() = runTest { + val analytics = callAnalytics(backgroundScope) + analytics.joinAnalyticsStateHolder.updateStage(Stage.IN_PROGRESS) + val publisher = mockk() + every { publisher.iceState } returns MutableStateFlow( + PeerConnection.IceConnectionState.CONNECTED, + ) + val subscriber = mockk() + every { subscriber.iceState } returns MutableStateFlow( + PeerConnection.IceConnectionState.DISCONNECTED, + ) + val session = mockk() + every { session.publisher } returns MutableStateFlow(publisher) + every { session.subscriber } returns MutableStateFlow(subscriber) + + analytics.onCallLeave(MutableStateFlow(session), userLeave) + + verify(exactly = 1) { + reporter.abortAllPostCallInFlight( + PeerConnection.IceConnectionState.CONNECTED, + PeerConnection.IceConnectionState.DISCONNECTED, + AnalyticsCallAbortReason.CLIENT_ABORTED.name, + any(), + ) + } + } + + @Test + fun `stopObservers cancels the peer connection observer job`() = runTest { + val analytics = callAnalytics(backgroundScope) + val observerJob = Job() + analytics.peerConnectionAnalytics.stateHolder.updatePeerConnectionObserverJob(observerJob) + + analytics.stopObservers() + + assertTrue(observerJob.isCancelled) + assertNull( + analytics.peerConnectionAnalytics.stateHolder.state.value.peerConnectionObserverJob, + ) + } + + @Test + fun `resetAfterJoinSuccess clears the first video frame dedupe state`() = runTest { + val analytics = callAnalytics(backgroundScope) + analytics.videoAnalytics.stageId.value = "stage-x" + + analytics.resetAfterJoinSuccess() + + assertEquals("", analytics.videoAnalytics.stageId.value) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt new file mode 100644 index 00000000000..1a51c2a6792 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.analytics.call.observer.model.JoinAnalyticsModel +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.analytics.reporting.model.AnalyticsCallAbortReason +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test + +class JoinAnalyticsTest { + + private val reporter = mockk(relaxed = true) + private val stateHolder = JoinAnalyticsStateHolder() + private var joinSuccessCount = 0 + private lateinit var joinAnalytics: JoinAnalytics + + @Before + fun setup() { + joinSuccessCount = 0 + joinAnalytics = JoinAnalytics("call-1", "default", reporter, stateHolder) { + joinSuccessCount++ + } + every { + reporter.reportCoordinatorJoinInitiated(any(), any(), any(), any()) + } returns "stage-1" + } + + @Test + fun `onJoinFunctionStart stores a fresh attempt id and reports the sdk join`() { + joinAnalytics.onJoinFunctionStart() + + val attemptId = stateHolder.state.value.joinStageAttemptId + assertNotNull(attemptId) + verify(exactly = 1) { + reporter.reportSdkMethodJoinInitiated( + callId = "call-1", + callType = "default", + joinStageAttemptId = attemptId!!, + ) + } + } + + @Test + fun `onJoinRequestStart reports the coordinator join and moves the stage in progress`() { + joinAnalytics.onJoinRequestStart(JoinReason.FirstAttempt) + + assertEquals(Stage.IN_PROGRESS, stateHolder.state.value.joinStage) + assertEquals("stage-1", stateHolder.state.value.stageId) + assertEquals(JoinReason.FirstAttempt, stateHolder.state.value.joinReason) + verify(exactly = 1) { + reporter.reportCoordinatorJoinInitiated(any(), any(), any(), any()) + } + } + + @Test + fun `onJoinRequestStart while already in progress does not report again`() { + joinAnalytics.onJoinRequestStart(JoinReason.FirstAttempt) + joinAnalytics.onJoinRequestStart(JoinReason.FirstAttempt) + + verify(exactly = 1) { + reporter.reportCoordinatorJoinInitiated(any(), any(), any(), any()) + } + } + + @Test + fun `a rejoin generates a new attempt id while a first attempt keeps it`() { + stateHolder.updateJoinStageAttemptId("original-attempt") + + joinAnalytics.onJoinRequestStart(JoinReason.FirstAttempt) + assertEquals("original-attempt", stateHolder.state.value.joinStageAttemptId) + + joinAnalytics.resetStage() + joinAnalytics.onJoinRequestStart(JoinReason.ReJoin) + assertNotEquals("original-attempt", stateHolder.state.value.joinStageAttemptId) + } + + @Test + fun `onJoinRequestSuccess completes the stage and notifies the callback`() { + joinAnalytics.onJoinRequestStart(JoinReason.FirstAttempt) + + joinAnalytics.onJoinRequestSuccess(JoinAnalyticsModel(retryAttempt = 2), "session-9") + + verify(exactly = 1) { + reporter.reportCoordinatorJoinCompleted("stage-1", true, 2, null, null, "session-9") + } + assertEquals(1, joinSuccessCount) + assertEquals(Stage.COMPLETED, stateHolder.state.value.joinStage) + assertEquals("session-9", stateHolder.state.value.callSessionId) + } + + @Test + fun `onJoinRequestSuccess without a started stage does nothing`() { + joinAnalytics.onJoinRequestSuccess(JoinAnalyticsModel(retryAttempt = 0), "session-9") + + verify(exactly = 0) { + reporter.reportCoordinatorJoinCompleted(any(), any(), any(), any(), any(), any()) + } + assertEquals(0, joinSuccessCount) + } + + @Test + fun `onJoinRequestPermanentError completes the stage as failure`() { + joinAnalytics.onJoinRequestStart(JoinReason.FirstAttempt) + + joinAnalytics.onJoinRequestPermanentError( + retryCount = 3, + AnalyticsCallAbortReason.SERVER_ERROR.name, + message = "boom", + ) + + verify(exactly = 1) { + reporter.reportCoordinatorJoinCompleted( + "stage-1", + false, + 3, + "boom", + AnalyticsCallAbortReason.SERVER_ERROR.name, + null, + ) + } + assertEquals(Stage.COMPLETED, stateHolder.state.value.joinStage) + assertEquals(0, joinSuccessCount) + } + + @Test + fun `onJoinRequestRetryExhausted behaves like a permanent error`() { + joinAnalytics.onJoinRequestStart(JoinReason.FirstAttempt) + + joinAnalytics.onJoinRequestRetryExhausted( + retryCount = 5, + AnalyticsCallAbortReason.RETRY_EXHAUSTED.name, + message = "exhausted", + ) + + verify(exactly = 1) { + reporter.reportCoordinatorJoinCompleted( + "stage-1", + false, + 5, + "exhausted", + AnalyticsCallAbortReason.RETRY_EXHAUSTED.name, + null, + ) + } + assertEquals(Stage.COMPLETED, stateHolder.state.value.joinStage) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsTest.kt new file mode 100644 index 00000000000..60a78e406bd --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/PeerConnectionAnalyticsTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.analytics.reporting.model.PeerConnectionRole +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.connection.Publisher +import io.getstream.video.android.core.call.connection.Subscriber +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.webrtc.PeerConnection + +@OptIn(ExperimentalCoroutinesApi::class) +class PeerConnectionAnalyticsTest { + + private val reporter = mockk(relaxed = true) + private val joinHolder = JoinAnalyticsStateHolder() + private val sfuHolder = SfuAnalyticsStateHolder() + private val stateHolder = PeerConnectionAnalyticsStateHolder() + + private fun analytics(scope: CoroutineScope) = PeerConnectionAnalytics( + "call-1", + "default", + scope, + reporter, + joinHolder, + sfuHolder, + stateHolder, + ) + + private fun mockSession( + publisherState: PeerConnection.PeerConnectionState?, + publisherIceState: PeerConnection.IceConnectionState?, + ): RtcSession { + val publisher = mockk() + every { publisher.state } returns MutableStateFlow(publisherState) + every { publisher.iceState } returns MutableStateFlow(publisherIceState) + + val subscriber = mockk() + every { subscriber.state } returns + MutableStateFlow(null) + every { subscriber.iceState } returns + MutableStateFlow(null) + + val session = mockk() + every { session.publisher } returns MutableStateFlow(publisher) + every { session.subscriber } returns MutableStateFlow(subscriber) + return session + } + + @Test + fun `onPeerConnectionStateChanged forwards the shared analytics context`() { + joinHolder.update(JoinReason.ReJoin, "attempt-7") + joinHolder.updateCallSessionId("session-7") + sfuHolder.updateSfuId("sfu-7") + + analytics(CoroutineScope(Dispatchers.Unconfined)).onPeerConnectionStateChanged( + peerConnectionHashCode = 42, + role = PeerConnectionRole.SUBSCRIBE, + iceState = PeerConnection.IceConnectionState.CHECKING, + peerConnectionState = PeerConnection.PeerConnectionState.CONNECTING, + ) + + verify(exactly = 1) { + reporter.onPeerConnectionStateChanged( + peerConnectionHashCode = 42, + callId = "call-1", + callType = "default", + joinStageAttemptId = "attempt-7", + callSessionId = "session-7", + sfuId = "sfu-7", + joinReason = JoinReason.ReJoin, + role = PeerConnectionRole.SUBSCRIBE, + iceState = PeerConnection.IceConnectionState.CHECKING, + peerConnectionState = PeerConnection.PeerConnectionState.CONNECTING, + ) + } + } + + @Test + fun `a connecting publisher marks the stage in progress and notifies the reporter`() = runTest { + val session = mockSession( + publisherState = PeerConnection.PeerConnectionState.CONNECTING, + publisherIceState = PeerConnection.IceConnectionState.CHECKING, + ) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + + analytics(scope).observePeerConnections(MutableStateFlow(session)) + runCurrent() + + assertEquals(Stage.IN_PROGRESS, stateHolder.state.value.publisherStage) + verify { + reporter.onPeerConnectionStateChanged( + peerConnectionHashCode = any(), + callId = "call-1", + callType = "default", + joinStageAttemptId = any(), + callSessionId = any(), + sfuId = any(), + joinReason = any(), + role = PeerConnectionRole.PUBLISH, + iceState = PeerConnection.IceConnectionState.CHECKING, + peerConnectionState = PeerConnection.PeerConnectionState.CONNECTING, + ) + } + scope.cancel() + } + + @Test + fun `a connected publisher resets the stage back to completed`() = runTest { + val session = mockSession( + publisherState = PeerConnection.PeerConnectionState.CONNECTED, + publisherIceState = PeerConnection.IceConnectionState.CONNECTED, + ) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + + analytics(scope).observePeerConnections(MutableStateFlow(session)) + runCurrent() + + assertEquals(Stage.COMPLETED, stateHolder.state.value.publisherStage) + scope.cancel() + } + + @Test + fun `stop cancels the observer job and clears it from the state holder`() = runTest { + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + val peerConnectionAnalytics = analytics(scope) + + peerConnectionAnalytics.observePeerConnections(MutableStateFlow(null)) + runCurrent() + val observerJob = stateHolder.state.value.peerConnectionObserverJob + assertNotNull(observerJob) + + peerConnectionAnalytics.stop() + + assertTrue(observerJob!!.isCancelled) + assertNull(stateHolder.state.value.peerConnectionObserverJob) + scope.cancel() + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalyticsTest.kt new file mode 100644 index 00000000000..fee916cdee2 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/SfuAnalyticsTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.call.observer.model.Stage +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.mockk.Called +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class SfuAnalyticsTest { + + private val reporter = mockk(relaxed = true) + private val joinHolder = JoinAnalyticsStateHolder() + private val sfuHolder = SfuAnalyticsStateHolder() + + @Before + fun setup() { + every { + reporter.reportSfuWsJoinInitiated(any(), any(), any(), any(), any(), any(), any()) + } returns "ws-stage-1" + joinHolder.update(JoinReason.FirstAttempt, "attempt-1") + joinHolder.updateCallSessionId("session-1") + sfuHolder.updateSfuId("sfu-1") + } + + private fun sfuAnalytics(enabled: Boolean = true) = SfuAnalytics( + enabled, + "call-1", + "default", + MutableStateFlow(RealtimeConnection.PreJoin), + CoroutineScope(Dispatchers.Unconfined), + reporter, + joinHolder, + sfuHolder, + ) + + @Test + fun `onSfuWsInitiated reports with the shared context and stores the stage id`() { + sfuAnalytics().onSfuWsInitiated(wasPreviouslyConnected = false) + + assertEquals(Stage.IN_PROGRESS, sfuHolder.stage.value) + assertEquals("ws-stage-1", sfuHolder.stageId.value) + verify(exactly = 1) { + reporter.reportSfuWsJoinInitiated( + sfuId = "sfu-1", + callId = "call-1", + callType = "default", + joinStageAttemptId = "attempt-1", + callSessionId = "session-1", + joinReason = JoinReason.FirstAttempt, + wasPreviouslyConnected = false, + ) + } + } + + @Test + fun `a second onSfuWsInitiated while in progress is ignored`() { + val analytics = sfuAnalytics() + + analytics.onSfuWsInitiated(wasPreviouslyConnected = false) + analytics.onSfuWsInitiated(wasPreviouslyConnected = false) + + verify(exactly = 1) { + reporter.reportSfuWsJoinInitiated(any(), any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun `onSfuWsCompleted reports with the stored stage id and resets the stage`() { + val analytics = sfuAnalytics() + analytics.onSfuWsInitiated(wasPreviouslyConnected = false) + + analytics.onSfuWsCompleted(success = true, retryCount = 1) + + verify(exactly = 1) { + reporter.reportSfuWsJoinCompleted( + stageId = "ws-stage-1", + joinStageAttemptId = "attempt-1", + success = true, + retryCount = 1, + failureReason = null, + failureCode = null, + ) + } + assertEquals(Stage.COMPLETED, sfuHolder.stage.value) + } + + @Test + fun `onSfuWsCompleted forwards failure details`() { + val analytics = sfuAnalytics() + analytics.onSfuWsInitiated(wasPreviouslyConnected = false) + + analytics.onSfuWsCompleted( + success = false, + retryCount = 4, + failureReason = "ws dropped", + failureCode = "WS_ERROR", + ) + + verify(exactly = 1) { + reporter.reportSfuWsJoinCompleted( + stageId = "ws-stage-1", + joinStageAttemptId = "attempt-1", + success = false, + retryCount = 4, + failureReason = "ws dropped", + failureCode = "WS_ERROR", + ) + } + } + + @Test + fun `onSfuWsCompleted without an in-progress stage does nothing`() { + sfuAnalytics().onSfuWsCompleted(success = true, retryCount = 0) + + verify(exactly = 0) { + reporter.reportSfuWsJoinCompleted(any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun `disabled analytics never reports`() { + val analytics = sfuAnalytics(enabled = false) + + analytics.onSfuWsInitiated(wasPreviouslyConnected = false) + analytics.onSfuWsCompleted(success = true, retryCount = 0) + + verify { reporter wasNot Called } + assertEquals(Stage.NOT_STARTED, sfuHolder.stage.value) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/VideoAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/VideoAnalyticsTest.kt new file mode 100644 index 00000000000..59b639ce778 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/VideoAnalyticsTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.call.observer + +import io.getstream.video.android.core.ParticipantState +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.connection.Subscriber +import io.getstream.video.android.core.model.VideoTrack +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import stream.video.sfu.models.TrackType + +class VideoAnalyticsTest { + + private val reporter = mockk(relaxed = true) + private val joinHolder = JoinAnalyticsStateHolder() + private val sfuHolder = SfuAnalyticsStateHolder() + + private val webRtcTrack = mockk() + private val remoteSubscriber = mockk() + private val rtcSession = mockk() + private val myParticipant = mockk() + + private lateinit var videoAnalytics: VideoAnalytics + + @Before + fun setup() { + every { webRtcTrack.id() } returns "track-1" + every { myParticipant.sessionId } returns "local-session" + every { remoteSubscriber.getTrack(any(), any()) } returns + VideoTrack(streamId = "remote-session", video = webRtcTrack) + every { rtcSession.subscriber } returns MutableStateFlow(remoteSubscriber) + every { + reporter.reportFirstRemoteVideoFrameRendered(any(), any(), any(), any(), any(), any(), any()) + } returns "video-stage-1" + + videoAnalytics = VideoAnalytics( + "call-1", + "default", + MutableStateFlow(myParticipant), + reporter, + joinHolder, + sfuHolder, + ) + } + + private fun render( + trackType: TrackType = TrackType.TRACK_TYPE_VIDEO, + videoSessionId: String = "remote-session", + callSessionId: String = "local-session", + session: RtcSession? = rtcSession, + ) = videoAnalytics.firstVideoFrameRendered( + trackType = trackType, + width = 1280, + height = 720, + rtcSession = session, + videoSessionId = videoSessionId, + callSessionId = callSessionId, + ) + + @Test + fun `the first remote video frame is reported once with the track id`() { + render() + + assertEquals("video-stage-1", videoAnalytics.stageId.value) + verify(exactly = 1) { + reporter.reportFirstRemoteVideoFrameRendered( + any(), + any(), + any(), + any(), + any(), + any(), + "track-1", + ) + } + } + + @Test + fun `subsequent frames are not reported again`() { + render() + render() + + verify(exactly = 1) { + reporter.reportFirstRemoteVideoFrameRendered( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `a screen share frame also counts as a first video frame`() { + render(trackType = TrackType.TRACK_TYPE_SCREEN_SHARE) + + verify(exactly = 1) { + reporter.reportFirstRemoteVideoFrameRendered( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `a local participant frame is ignored`() { + render(videoSessionId = "local-session", callSessionId = "local-session") + + verify(exactly = 0) { + reporter.reportFirstRemoteVideoFrameRendered( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + assertEquals("", videoAnalytics.stageId.value) + } + + @Test + fun `an audio track frame is ignored`() { + render(trackType = TrackType.TRACK_TYPE_AUDIO) + + verify(exactly = 0) { + reporter.reportFirstRemoteVideoFrameRendered( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `a missing rtc session is ignored`() { + render(session = null) + + verify(exactly = 0) { + reporter.reportFirstRemoteVideoFrameRendered( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `reset allows the first frame to be reported again`() { + render() + videoAnalytics.reset() + render() + + verify(exactly = 2) { + reporter.reportFirstRemoteVideoFrameRendered( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/coordinator/CoordinatorAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/coordinator/CoordinatorAnalyticsTest.kt new file mode 100644 index 00000000000..bcd7f4d989f --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/coordinator/CoordinatorAnalyticsTest.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.coordinator + +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.socket.coordinator.CoordinatorSocketStateService +import io.getstream.video.android.core.socket.coordinator.state.VideoSocketConnectionType +import io.getstream.video.android.core.socket.coordinator.state.VideoSocketState +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CoordinatorAnalyticsTest { + + private val reporter = mockk(relaxed = true) + private val socketState = + MutableStateFlow(VideoSocketState.Disconnected.Stopped) + private var previousRetryAttempts = 0 + + @Before + fun setup() { + mockkObject(StreamVideo) + val streamVideo = mockk(relaxed = true) + every { streamVideo.userId } returns "user-1" + every { StreamVideo.instanceOrNull() } returns streamVideo + + every { reporter.reportCoordinatorWSInitiated() } returns "ws-stage-1" + previousRetryAttempts = CoordinatorSocketStateService.lastRetryAttempts + CoordinatorSocketStateService.lastRetryAttempts = 2 + } + + @After + fun tearDown() { + unmockkObject(StreamVideo) + CoordinatorSocketStateService.lastRetryAttempts = previousRetryAttempts + } + + private fun TestScope.startedAnalytics(): CoordinatorAnalytics { + val analytics = CoordinatorAnalytics( + CoroutineScope(UnconfinedTestDispatcher(testScheduler)), + reporter, + ) + analytics.startObserver(socketState) + return analytics + } + + private fun connecting(type: VideoSocketConnectionType) = + VideoSocketState.Connecting(mockk(), type) + + @Test + fun `an initial connection reports coordinator ws initiated`() = runTest { + startedAnalytics() + + socketState.value = connecting(VideoSocketConnectionType.INITIAL_CONNECTION) + runCurrent() + + verify(exactly = 1) { reporter.reportCoordinatorWSInitiated() } + } + + @Test + fun `nothing is reported while the user id is not available`() = runTest { + every { StreamVideo.instanceOrNull() } returns null + startedAnalytics() + + socketState.value = connecting(VideoSocketConnectionType.INITIAL_CONNECTION) + runCurrent() + + verify(exactly = 0) { reporter.reportCoordinatorWSInitiated() } + } + + @Test + fun `automatic and forced reconnections are not reported as initiated`() = runTest { + startedAnalytics() + + socketState.value = connecting(VideoSocketConnectionType.AUTOMATIC_RECONNECTION) + runCurrent() + socketState.value = connecting(VideoSocketConnectionType.FORCE_RECONNECTION) + runCurrent() + + verify(exactly = 0) { reporter.reportCoordinatorWSInitiated() } + } + + @Test + fun `connected after initiated reports completion with the retry count`() = runTest { + startedAnalytics() + + socketState.value = connecting(VideoSocketConnectionType.INITIAL_CONNECTION) + runCurrent() + socketState.value = VideoSocketState.Connected(mockk()) + runCurrent() + + verify(exactly = 1) { + reporter.reportCoordinatorWSCompleted("ws-stage-1", true, 2, null, null) + } + } + + @Test + fun `connected without a prior initiated stage reports nothing`() = runTest { + startedAnalytics() + + socketState.value = VideoSocketState.Connected(mockk()) + runCurrent() + + verify(exactly = 0) { + reporter.reportCoordinatorWSCompleted(any(), any(), any(), any(), any()) + } + } + + @Test + fun `a permanent disconnect after initiated reports a failed completion`() = runTest { + startedAnalytics() + + socketState.value = connecting(VideoSocketConnectionType.INITIAL_CONNECTION) + runCurrent() + socketState.value = VideoSocketState.Disconnected.DisconnectedPermanently(mockk()) + runCurrent() + + verify(exactly = 1) { + reporter.reportCoordinatorWSCompleted("ws-stage-1", false, 2, null, null) + } + } + + @Test + fun `endObserver stops reporting socket state changes`() = runTest { + val analytics = startedAnalytics() + + analytics.endObserver() + socketState.value = connecting(VideoSocketConnectionType.INITIAL_CONNECTION) + runCurrent() + + verify(exactly = 0) { reporter.reportCoordinatorWSInitiated() } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventReporterTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventReporterTest.kt new file mode 100644 index 00000000000..c9802ebc622 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/reporting/ClientEventReporterTest.kt @@ -0,0 +1,462 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting + +import io.getstream.android.video.generated.models.ClientEvent +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.analytics.reporting.dispatcher.EventDispatcher +import io.getstream.video.android.core.analytics.reporting.model.AnalyticsCallAbortReason +import io.getstream.video.android.core.analytics.reporting.model.EventOutcome +import io.getstream.video.android.core.analytics.reporting.model.EventStage +import io.getstream.video.android.core.analytics.reporting.model.EventType +import io.getstream.video.android.core.analytics.reporting.model.PeerConnectionRole +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.webrtc.PeerConnection + +class ClientEventReporterTest { + + private class RecordingEventDispatcher : EventDispatcher { + val sent = mutableListOf() + val batches = mutableListOf>() + + override fun send(event: ClientEvent) { + sent += event + } + + override fun sendAll(events: List) { + batches += events + sent += events + } + } + + private lateinit var dispatcher: RecordingEventDispatcher + private lateinit var reporter: ClientEventReporter + + @Before + fun setup() { + dispatcher = RecordingEventDispatcher() + reporter = ClientEventReporter( + sender = dispatcher, + userAgent = { "test-agent" }, + sdkVersion = "1.0.0", + ) + } + + private fun openCoordinatorJoin() = reporter.reportCoordinatorJoinInitiated( + callId = "call-1", + callType = "default", + joinStageAttemptId = "attempt-1", + joinReason = JoinReason.FirstAttempt, + ) + + private fun openSfuWsJoin() = reporter.reportSfuWsJoinInitiated( + sfuId = "sfu-1", + callId = "call-1", + callType = "default", + joinStageAttemptId = "attempt-1", + callSessionId = "session-1", + joinReason = JoinReason.FirstAttempt, + wasPreviouslyConnected = false, + ) + + private fun pcStateChanged( + pcState: PeerConnection.PeerConnectionState?, + iceState: PeerConnection.IceConnectionState? = null, + role: PeerConnectionRole = PeerConnectionRole.PUBLISH, + pcHashCode: Int = 100, + ) = reporter.onPeerConnectionStateChanged( + peerConnectionHashCode = pcHashCode, + callId = "call-1", + callType = "default", + joinStageAttemptId = "attempt-1", + callSessionId = "session-1", + sfuId = "sfu-1", + joinReason = JoinReason.FirstAttempt, + role = role, + iceState = iceState, + peerConnectionState = pcState, + ) + + // --- Coordinator WS --- + + @Test + fun `coordinator ws initiated sends an initiated event and returns its stage id`() { + val stageId = reporter.reportCoordinatorWSInitiated() + + assertEquals(1, dispatcher.sent.size) + val event = dispatcher.sent.first() + assertTrue(stageId.isNotEmpty()) + assertEquals(stageId, event.stageId) + assertEquals(EventStage.CoordinatorWs.value, event.stage) + assertEquals(EventType.INITIATED.value, event.eventType) + assertEquals("test-agent", event.userAgent) + assertEquals("1.0.0", event.sdkVersion) + assertTrue(event.coordinatorConnectId.orEmpty().isNotEmpty()) + } + + @Test + fun `coordinator ws completed with success reports the outcome and replays pending events`() { + val stageId = reporter.reportCoordinatorWSInitiated() + + reporter.reportCoordinatorWSCompleted(stageId, success = true, retryCount = 2) + + assertEquals(2, dispatcher.sent.size) + val completed = dispatcher.sent.last() + assertEquals(EventType.COMPLETED.value, completed.eventType) + assertEquals(EventOutcome.SUCCESS.value, completed.outcome) + assertEquals(2, completed.retryCountAttempt) + assertNotNull(completed.elapsedTime) + } + + @Test + fun `coordinator ws completed with failure does not replay pending events`() { + val stageId = reporter.reportCoordinatorWSInitiated() + + reporter.reportCoordinatorWSCompleted( + stageId, + success = false, + retryCount = 3, + failureCode = "WS_FAILED", + failureReason = "socket closed", + ) + + val completed = dispatcher.sent.last() + assertEquals(EventOutcome.FAILURE.value, completed.outcome) + assertEquals("WS_FAILED", completed.retryFailureCode) + assertEquals("socket closed", completed.retryFailureReason) + } + + @Test + fun `coordinator ws completed with an unknown stage id sends no event`() { + reporter.reportCoordinatorWSCompleted("unknown-stage", success = true) + + assertTrue(dispatcher.sent.isEmpty()) + } + + // --- SDK join + coordinator join --- + + @Test + fun `sdk method join initiated sends a JoinInitiated event`() { + reporter.reportSdkMethodJoinInitiated("call-1", "default", "attempt-1") + + val event = dispatcher.sent.single() + assertEquals(EventStage.Call.JOIN_INITIATED.value, event.stage) + assertEquals(EventType.INITIATED.value, event.eventType) + assertEquals("call-1", event.id) + assertEquals("default", event.type) + assertEquals("attempt-1", event.joinAttemptId) + } + + @Test + fun `coordinator join initiated and completed are paired by stage id`() { + val stageId = openCoordinatorJoin() + + reporter.reportCoordinatorJoinCompleted( + stageId = stageId, + success = true, + retryCount = 1, + callSessionId = "session-1", + ) + + assertEquals(2, dispatcher.sent.size) + val completed = dispatcher.sent.last() + assertEquals(EventStage.Call.COORDINATOR_JOIN.value, completed.stage) + assertEquals(EventType.COMPLETED.value, completed.eventType) + assertEquals(EventOutcome.SUCCESS.value, completed.outcome) + assertEquals(stageId, completed.stageId) + assertEquals("session-1", completed.callSessionId) + assertEquals("attempt-1", completed.joinAttemptId) + assertEquals(JoinReason.FirstAttempt.message, completed.joinReason) + assertNotNull(completed.elapsedTime) + } + + @Test + fun `completing the same coordinator join twice sends only one completed event`() { + val stageId = openCoordinatorJoin() + + reporter.reportCoordinatorJoinCompleted(stageId, success = true, retryCount = 0) + reporter.reportCoordinatorJoinCompleted(stageId, success = true, retryCount = 0) + + assertEquals(2, dispatcher.sent.size) + } + + @Test + fun `coordinator join failure carries the failure reason`() { + val stageId = openCoordinatorJoin() + + reporter.reportCoordinatorJoinCompleted( + stageId = stageId, + success = false, + retryCount = 3, + failureReason = "join exhausted", + ) + + val completed = dispatcher.sent.last() + assertEquals(EventOutcome.FAILURE.value, completed.outcome) + assertEquals("join exhausted", completed.retryFailureReason) + assertEquals(3, completed.retryCountAttempt) + } + + // --- SFU WS join --- + + @Test + fun `sfu ws join initiated and completed are paired by stage id`() { + val stageId = openSfuWsJoin() + + reporter.reportSfuWsJoinCompleted( + stageId = stageId, + joinStageAttemptId = "attempt-1", + success = true, + retryCount = 0, + ) + + assertEquals(2, dispatcher.sent.size) + val completed = dispatcher.sent.last() + assertEquals(EventStage.Call.WS_JOIN.value, completed.stage) + assertEquals(EventOutcome.SUCCESS.value, completed.outcome) + assertEquals(stageId, completed.stageId) + assertEquals("sfu-1", completed.sfuId) + assertEquals("session-1", completed.callSessionId) + } + + // --- Peer connection state machine --- + + @Test + fun `CONNECTING opens a session and CONNECTED completes it as success`() { + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + ) + + assertEquals(1, dispatcher.sent.size) + val initiated = dispatcher.sent.first() + assertEquals(EventStage.Call.PEER_CONNECTION_CONNECT.value, initiated.stage) + assertEquals(EventType.INITIATED.value, initiated.eventType) + assertEquals(PeerConnectionRole.PUBLISH.value, initiated.peerConnection) + assertEquals(false, initiated.wasPreviouslyConnected) + + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTED, + iceState = PeerConnection.IceConnectionState.CONNECTED, + ) + + assertEquals(2, dispatcher.sent.size) + val completed = dispatcher.sent.last() + assertEquals(EventType.COMPLETED.value, completed.eventType) + assertEquals(EventOutcome.SUCCESS.value, completed.outcome) + assertEquals(initiated.stageId, completed.stageId) + } + + @Test + fun `FAILED completes the session as a connectivity failure`() { + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + ) + + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.FAILED, + iceState = PeerConnection.IceConnectionState.FAILED, + ) + + val completed = dispatcher.sent.last() + assertEquals(EventOutcome.FAILURE.value, completed.outcome) + assertEquals("ICE_CONNECTIVITY_FAILED", completed.retryFailureCode) + assertEquals("ICE connectivity checks failed", completed.retryFailureReason) + } + + @Test + fun `CONNECTED with a null ice state is ignored`() { + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + ) + + pcStateChanged(pcState = PeerConnection.PeerConnectionState.CONNECTED, iceState = null) + + assertEquals(1, dispatcher.sent.size) + } + + @Test + fun `a reconnect after CONNECTED is flagged as previously connected`() { + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + pcHashCode = 1, + ) + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTED, + iceState = PeerConnection.IceConnectionState.CONNECTED, + pcHashCode = 1, + ) + + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + pcHashCode = 2, + ) + + val reconnectInitiated = dispatcher.sent.last() + assertEquals(EventType.INITIATED.value, reconnectInitiated.eventType) + assertEquals(true, reconnectInitiated.wasPreviouslyConnected) + } + + @Test + fun `CONNECTED without an open session sends nothing`() { + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTED, + iceState = PeerConnection.IceConnectionState.CONNECTED, + ) + + assertTrue(dispatcher.sent.isEmpty()) + } + + @Test + fun `sessions are completed per peer connection instance`() { + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + pcHashCode = 1, + ) + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + pcHashCode = 2, + ) + val firstStageId = dispatcher.sent.first().stageId + + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTED, + iceState = PeerConnection.IceConnectionState.CONNECTED, + pcHashCode = 1, + ) + + assertEquals(3, dispatcher.sent.size) + val completed = dispatcher.sent.last() + assertEquals(EventType.COMPLETED.value, completed.eventType) + assertEquals(firstStageId, completed.stageId) + } + + @Test + fun `publisher and subscriber sessions are tracked independently`() { + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + role = PeerConnectionRole.PUBLISH, + pcHashCode = 1, + ) + pcStateChanged( + pcState = PeerConnection.PeerConnectionState.CONNECTING, + iceState = PeerConnection.IceConnectionState.CHECKING, + role = PeerConnectionRole.SUBSCRIBE, + pcHashCode = 2, + ) + + assertEquals(2, dispatcher.sent.size) + assertTrue(dispatcher.sent.all { it.eventType == EventType.INITIATED.value }) + } + + // --- Abort flush --- + + @Test + fun `abortAllPostCallInFlight flushes open call sessions as failures and clears them`() { + reporter.reportCoordinatorWSInitiated() + val joinStageId = openCoordinatorJoin() + openSfuWsJoin() + dispatcher.sent.clear() + + reporter.abortAllPostCallInFlight( + publisherIceState = null, + subscriberIceState = null, + AnalyticsCallAbortReason.CLIENT_ABORTED.name, + "user left the call", + ) + + assertEquals(1, dispatcher.batches.size) + val aborted = dispatcher.batches.first() + assertEquals(2, aborted.size) + assertTrue(aborted.all { it.eventType == EventType.COMPLETED.value }) + assertTrue(aborted.all { it.outcome == EventOutcome.FAILURE.value }) + assertTrue( + aborted.all { it.retryFailureCode == AnalyticsCallAbortReason.CLIENT_ABORTED.code }, + ) + assertTrue( + aborted.all { it.retryFailureReason == "user left the call" }, + ) + + dispatcher.sent.clear() + reporter.reportCoordinatorJoinCompleted(joinStageId, success = true, retryCount = 0) + assertTrue(dispatcher.sent.isEmpty()) + } + + // --- Media readiness + permissions --- + + @Test + fun `first video frame sends an initiated event with the track id`() { + reporter.reportFirstRemoteVideoFrameRendered( + sfuId = "sfu-1", + callId = "call-1", + callType = "default", + joinStageAttemptId = "attempt-1", + callSessionId = "session-1", + joinReason = JoinReason.FirstAttempt, + trackId = "track-1", + ) + + val event = dispatcher.sent.single() + assertEquals(EventStage.Call.FIRST_VIDEO_FRAME_RENDERED.value, event.stage) + assertEquals(EventType.INITIATED.value, event.eventType) + assertEquals("track-1", event.trackId) + } + + @Test + fun `first audio frame sends an initiated event`() { + reporter.reportFirstAudioFrameRendered( + sfuId = "sfu-1", + callId = "call-1", + callType = "default", + joinStageAttemptId = "attempt-1", + callSessionId = "session-1", + joinReason = JoinReason.FirstAttempt, + ) + + val event = dispatcher.sent.single() + assertEquals(EventStage.Call.FIRST_AUDIO_FRAME_RENDERED.value, event.stage) + assertEquals(EventType.INITIATED.value, event.eventType) + } + + @Test + fun `media permission status maps granted flags to permission text`() { + reporter.reportMediaPermissionStatus( + callId = "call-1", + callType = "default", + joinStageAttemptId = "attempt-1", + joinReason = JoinReason.FirstAttempt, + isCameraGranted = true, + isMicrophoneGranted = false, + ) + + val event = dispatcher.sent.single() + assertEquals(EventStage.Call.MEDIA_DEVICE_PERMISSION.value, event.stage) + assertEquals("GRANTED", event.cameraPermissionStatus) + assertEquals("FAILED", event.microphonePermissionStatus) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/ImmediateEventDispatcherTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/ImmediateEventDispatcherTest.kt new file mode 100644 index 00000000000..94653354a51 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/reporting/dispatcher/ImmediateEventDispatcherTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.analytics.reporting.dispatcher + +import io.getstream.android.video.generated.apis.ProductvideoApi +import io.getstream.android.video.generated.models.ClientEvent +import io.getstream.android.video.generated.models.ReportClientEventResponse +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException + +@OptIn(ExperimentalCoroutinesApi::class) +class ImmediateEventDispatcherTest { + + private val api = mockk() + private val response = mockk() + + // Not backgroundScope: advanceUntilIdle() stops once no foreground tasks remain, + // so coroutines launched into backgroundScope would never run before verification. + private fun TestScope.dispatcher() = ImmediateEventDispatcher( + api, + CoroutineScope(StandardTestDispatcher(testScheduler)), + ) + + private fun event(id: String) = ClientEvent(id = id) + + private fun httpException(code: Int) = HttpException( + Response.error(code, "{}".toResponseBody("application/json".toMediaType())), + ) + + @Test + fun `send delivers the event to the api without persisting anything`() = runTest { + coEvery { api.reportClientCallEvent(any()) } returns response + val pendingEvent = event("e1") + + dispatcher().send(pendingEvent) + advanceUntilIdle() + + coVerify(exactly = 1) { + api.reportClientCallEvent(match { it.events == listOf(pendingEvent) }) + } + } + + @Test + fun `sendAll with an empty list never touches the api`() = runTest { + dispatcher().sendAll(emptyList()) + advanceUntilIdle() + + coVerify(exactly = 0) { api.reportClientCallEvent(any()) } + } + + @Test + fun `a transient failure is retried five times and then persisted`() = runTest { + coEvery { api.reportClientCallEvent(any()) } throws IOException("network down") + val pendingEvent = event("e1") + + dispatcher().send(pendingEvent) + advanceUntilIdle() + + coVerify(exactly = 5) { api.reportClientCallEvent(any()) } + } + + @Test + fun `a transient failure that recovers mid-retry is not persisted`() = runTest { + coEvery { + api.reportClientCallEvent(any()) + } throws IOException("flaky") andThenThrows IOException("flaky again") andThen response + + dispatcher().send(event("e1")) + advanceUntilIdle() + + coVerify(exactly = 3) { api.reportClientCallEvent(any()) } + } + + @Test + fun `server errors are retried and persisted on exhaustion`() = runTest { + coEvery { api.reportClientCallEvent(any()) } throws httpException(503) + + dispatcher().send(event("e1")) + advanceUntilIdle() + + coVerify(exactly = 5) { api.reportClientCallEvent(any()) } + } + + @Test + fun `client errors fail fast without retry or persistence`() = runTest { + coEvery { api.reportClientCallEvent(any()) } throws httpException(400) + + dispatcher().send(event("e1")) + advanceUntilIdle() + + coVerify(exactly = 1) { api.reportClientCallEvent(any()) } + } + + @Test + fun `retryPending with an empty store does nothing`() = runTest { + advanceUntilIdle() + + coVerify(exactly = 0) { api.reportClientCallEvent(any()) } + } + + @Test + fun `shouldRetry classifies transient and permanent failures`() { + val dispatcher = ImmediateEventDispatcher(api, CoroutineScope(Job())) + + assertTrue(dispatcher.shouldRetry(IOException())) + assertTrue(dispatcher.shouldRetry(SocketTimeoutException())) + assertTrue(dispatcher.shouldRetry(ConnectException())) + assertTrue(dispatcher.shouldRetry(UnknownHostException())) + assertTrue(dispatcher.shouldRetry(httpException(500))) + assertTrue(dispatcher.shouldRetry(httpException(599))) + + assertFalse(dispatcher.shouldRetry(httpException(400))) + assertFalse(dispatcher.shouldRetry(httpException(404))) + assertFalse(dispatcher.shouldRetry(IllegalStateException())) + assertFalse(dispatcher.shouldRetry(null)) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt index ce481c01ff7..0bbbcdc0eec 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/ServiceNotificationRetrieverTest.kt @@ -59,6 +59,8 @@ class ServiceNotificationRetrieverTest { serviceNotificationRetriever = ServiceNotificationRetriever() testCallId = StreamCallId(type = "default", id = "test-call-123") every { mockStreamVideoClient.scope } returns TestScope() + every { mockStreamVideoClient.state } returns mockk(relaxed = true) + every { mockStreamVideoClient.context } returns mockk(relaxed = true) call = Call(mockStreamVideoClient, "default", "test-call-123", mockk()) every { mockStreamVideoClient.call(testCallId.type, testCallId.id) } returns call @@ -72,6 +74,9 @@ class ServiceNotificationRetrieverTest { mockStreamVideoClient.getOngoingCallNotification(any(), any(), payload = any()) } returns mockNotification + every { mockStreamVideoClient.state } returns mockk(relaxed = true) + every { mockStreamVideoClient.context } returns mockk(relaxed = true) + // When val result = serviceNotificationRetriever.getNotificationPair( context = context, @@ -114,6 +119,9 @@ class ServiceNotificationRetrieverTest { @Test fun `getNotificationPair returns null notification for remove incoming call`() { + every { mockStreamVideoClient.state } returns mockk(relaxed = true) + every { mockStreamVideoClient.context } returns mockk(relaxed = true) + // When val result = serviceNotificationRetriever.getNotificationPair( context = context, @@ -130,6 +138,9 @@ class ServiceNotificationRetrieverTest { @Test fun `getNotificationPair returns null notification for unknown trigger`() { + every { mockStreamVideoClient.state } returns mockk(relaxed = true) + every { mockStreamVideoClient.context } returns mockk(relaxed = true) + // When val result = serviceNotificationRetriever.getNotificationPair( context = context, @@ -147,6 +158,8 @@ class ServiceNotificationRetrieverTest { @Test fun `service handles missing StreamVideo instance gracefully in notification generation`() { // Given - Using a real CallService instance but with mocked dependencies + every { mockStreamVideoClient.state } returns mockk(relaxed = true) + every { mockStreamVideoClient.context } returns mockk(relaxed = true) // When - Call getNotificationPair with minimal valid parameters val result = serviceNotificationRetriever.getNotificationPair( diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt index 0cf56c85357..5b021c8a194 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/notifications/internal/service/managers/CallServiceNotificationManagerTest.kt @@ -143,6 +143,8 @@ class CallServiceNotificationManagerTest { every { StreamVideo.instanceOrNull() } returns streamVideoClient every { streamVideoClient.scope } returns TestScope() + every { streamVideoClient.state } returns mockk(relaxed = true) + every { streamVideoClient.context } returns mockk(relaxed = true) val call = Call(streamVideoClient, callId.type, callId.id, mockk()) every { streamVideoClient.call(callId.type, callId.id) } returns call @@ -172,6 +174,8 @@ class CallServiceNotificationManagerTest { val streamVideoClient = mockk { every { this@mockk.streamNotificationManager } returns streamNotificationManager + every { this@mockk.state } returns mockk(relaxed = true) + every { this@mockk.context } returns mockk(relaxed = true) } every { streamVideoClient.scope } returns TestScope() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/FastReconnectIceRestartTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/FastReconnectIceRestartTest.kt index 1024f8032e9..77e7be93ddb 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/FastReconnectIceRestartTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/FastReconnectIceRestartTest.kt @@ -26,6 +26,7 @@ import io.getstream.video.android.core.MediaManagerImpl import io.getstream.video.android.core.ParticipantState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.analytics.call.observer.SfuAnalytics import io.getstream.video.android.core.call.FastReconnectResult import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectionResult @@ -139,6 +140,7 @@ class FastReconnectIceRestartTest { clientImpl = mockVideoClient, coroutineScope = testScope, sfuConnectionModuleProvider = { sfuConnectionModule }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ), ) } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/RtcSessionTest2.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/RtcSessionTest2.kt index 19ed1ab24d3..baada1245d9 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/RtcSessionTest2.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/RtcSessionTest2.kt @@ -25,6 +25,7 @@ import io.getstream.video.android.core.MediaManagerImpl import io.getstream.video.android.core.ParticipantState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.analytics.call.observer.SfuAnalytics import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectionResult import io.getstream.video.android.core.call.connection.Publisher @@ -160,6 +161,7 @@ class RtcSessionTest2 { clientImpl = mockVideoClient, coroutineScope = testScope, sfuConnectionModuleProvider = { mockk(relaxed = true) }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ), ) @@ -212,6 +214,7 @@ class RtcSessionTest2 { coroutineScope = testScope, remoteIceServers = remoteIceServers, sfuConnectionModuleProvider = { sfuSocketModule }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ), ) coJustRun { rtcSession.sendCallStats(any(), any(), any()) } @@ -262,6 +265,7 @@ class RtcSessionTest2 { coroutineScope = testScope, remoteIceServers = emptyList(), sfuConnectionModuleProvider = { sfuSocketModule }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ), ) coJustRun { rtcSession.sendCallStats(any(), any(), any()) } @@ -309,6 +313,7 @@ class RtcSessionTest2 { coroutineScope = testScope, remoteIceServers = emptyList(), sfuConnectionModuleProvider = { sfuSocketModule }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ), ) coJustRun { rtcSession.sendCallStats(any(), any(), any()) } @@ -363,6 +368,7 @@ class RtcSessionTest2 { coroutineScope = testScope, remoteIceServers = remoteIceServers, sfuConnectionModuleProvider = { mockk(relaxed = true) }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ), ) val subscriber = rtcSession.subscriber @@ -424,6 +430,7 @@ class RtcSessionTest2 { rtcSessionScope = testScope, remoteIceServers = emptyList(), sfuConnectionModuleProvider = { mockModule }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ) // Confirm publisher is null assertNull(rtcSession.publisher.value) @@ -472,6 +479,7 @@ class RtcSessionTest2 { coroutineScope = testScope, remoteIceServers = emptyList(), sfuConnectionModuleProvider = { mockk(relaxed = true) }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ) val mockPublisher = mockk(relaxed = true) rtcSession.publisher.value = mockPublisher @@ -519,6 +527,7 @@ class RtcSessionTest2 { coroutineScope = testScope, remoteIceServers = emptyList(), sfuConnectionModuleProvider = { mockk(relaxed = true) }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ) val subscriber = rtcSession.subscriber.value assertNotNull(subscriber) @@ -617,6 +626,7 @@ class RtcSessionTest2 { rtcSessionScope = testScope, remoteIceServers = emptyList(), sfuConnectionModuleProvider = { mockModule }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ), recordPrivateCalls = true, ) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/SfuConnectionRetryTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/SfuConnectionRetryTest.kt index 3c7e21aad83..f894103b86b 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/SfuConnectionRetryTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/SfuConnectionRetryTest.kt @@ -26,6 +26,7 @@ import io.getstream.video.android.core.MediaManagerImpl import io.getstream.video.android.core.ParticipantState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.analytics.call.observer.SfuAnalytics import io.getstream.video.android.core.base.DispatcherRule import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.events.JoinCallResponseEvent @@ -160,6 +161,7 @@ class SfuConnectionRetryTest { rtcSessionScope = testScope, remoteIceServers = emptyList(), sfuConnectionModuleProvider = { mockModule }, + sfuAnalytics = SfuAnalytics.getFakeSfuAnalytics(), ) }