From 908041a9b8269e5eb1ab433e77fe697765fa9377 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Thu, 25 Jun 2026 13:06:15 +0530 Subject: [PATCH 01/10] refactor(core): decompose Call into focused internal components Break the ~2,260-line Call class into 11 internal collaborators under call/components (CallApiClient, CallStatsReporter, CallRenderer, CallEventManager, CallMediaManager, CallSessionManager, CallIceConnectionMonitor, CallConnectivityMonitor, CallJoinCoordinator, CallReconnector, CallLifecycleManager). Call remains a thin, binary-compatible public facade that delegates to them; public API is unchanged (apiCheck passes). Update white-box reflection tests to target the new component owners after internals moved out of Call. Co-authored-by: Cursor --- .../io/getstream/video/android/core/Call.kt | 1814 +++-------------- .../core/call/components/CallApiClient.kt | 402 ++++ .../components/CallConnectivityMonitor.kt | 106 + .../core/call/components/CallEventManager.kt | 98 + .../components/CallIceConnectionMonitor.kt | 92 + .../call/components/CallJoinCoordinator.kt | 322 +++ .../call/components/CallLifecycleManager.kt | 146 ++ .../core/call/components/CallMediaManager.kt | 273 +++ .../core/call/components/CallReconnector.kt | 543 +++++ .../core/call/components/CallRenderer.kt | 209 ++ .../call/components/CallSessionManager.kt | 50 + .../core/call/components/CallStatsReporter.kt | 91 + .../core/CallFieldDeclarationOrderTest.kt | 18 +- .../core/reconnect/FailedSfuIdsTest.kt | 28 +- .../reconnect/ReconnectAttemptsCountTest.kt | 7 +- .../core/reconnect/ReconnectSessionIdTest.kt | 7 +- 16 files changed, 2626 insertions(+), 1580 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt 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 5243867ef84..bf10b5750fc 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 @@ -23,7 +23,6 @@ import android.os.PowerManager import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Stable import io.getstream.android.video.generated.models.AcceptCallResponse -import io.getstream.android.video.generated.models.AudioSettingsResponse import io.getstream.android.video.generated.models.BlockUserResponse import io.getstream.android.video.generated.models.CallSettingsRequest import io.getstream.android.video.generated.models.CallSettingsResponse @@ -47,94 +46,63 @@ import io.getstream.android.video.generated.models.StartTranscriptionResponse import io.getstream.android.video.generated.models.StopLiveResponse import io.getstream.android.video.generated.models.StopTranscriptionResponse import io.getstream.android.video.generated.models.UnpinResponse -import io.getstream.android.video.generated.models.UpdateCallMembersRequest import io.getstream.android.video.generated.models.UpdateCallMembersResponse -import io.getstream.android.video.generated.models.UpdateCallRequest import io.getstream.android.video.generated.models.UpdateCallResponse import io.getstream.android.video.generated.models.UpdateUserPermissionsResponse import io.getstream.android.video.generated.models.VideoEvent -import io.getstream.android.video.generated.models.VideoSettingsResponse import io.getstream.log.taggedLogger -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.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 -import io.getstream.video.android.core.call.SfuConnectionResult import io.getstream.video.android.core.call.audio.InputAudioFilter +import io.getstream.video.android.core.call.components.CallApiClient +import io.getstream.video.android.core.call.components.CallConnectivityMonitor +import io.getstream.video.android.core.call.components.CallEventManager +import io.getstream.video.android.core.call.components.CallIceConnectionMonitor +import io.getstream.video.android.core.call.components.CallJoinCoordinator +import io.getstream.video.android.core.call.components.CallLifecycleManager +import io.getstream.video.android.core.call.components.CallMediaManager +import io.getstream.video.android.core.call.components.CallReconnector +import io.getstream.video.android.core.call.components.CallRenderer +import io.getstream.video.android.core.call.components.CallSessionManager +import io.getstream.video.android.core.call.components.CallStatsReporter import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory -import io.getstream.video.android.core.call.connection.Subscriber import io.getstream.video.android.core.call.scope.ScopeProvider import io.getstream.video.android.core.call.scope.ScopeProviderImpl -import io.getstream.video.android.core.call.utils.SoundInputProcessor import io.getstream.video.android.core.call.video.VideoFilter -import io.getstream.video.android.core.call.video.YuvFrame import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings -import io.getstream.video.android.core.events.GoAwayEvent import io.getstream.video.android.core.events.JoinCallResponseEvent import io.getstream.video.android.core.events.VideoEventListener import io.getstream.video.android.core.internal.InternalStreamVideoApi -import io.getstream.video.android.core.internal.network.NetworkStateProvider -import io.getstream.video.android.core.model.AudioTrack -import io.getstream.video.android.core.model.MuteUsersData import io.getstream.video.android.core.model.PreferredVideoResolution import io.getstream.video.android.core.model.QueriedMembers import io.getstream.video.android.core.model.RejectReason import io.getstream.video.android.core.model.SortField -import io.getstream.video.android.core.model.UpdateUserPermissionsData import io.getstream.video.android.core.model.VideoTrack -import io.getstream.video.android.core.model.toIceServer -import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController import io.getstream.video.android.core.recording.RecordingType import io.getstream.video.android.core.socket.common.scope.ClientScope import io.getstream.video.android.core.socket.common.scope.UserScope -import io.getstream.video.android.core.utils.AtomicUnitCall -import io.getstream.video.android.core.utils.RampValueUpAndDownHelper import io.getstream.video.android.core.utils.debugOnly -import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.core.utils.safeCallWithDefault -import io.getstream.video.android.core.utils.toQueriedMembers import io.getstream.video.android.model.User import io.getstream.webrtc.android.ui.VideoTextureViewRenderer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.sync.Mutex import org.threeten.bp.OffsetDateTime import org.webrtc.EglBase -import org.webrtc.PeerConnection -import org.webrtc.RendererCommon -import org.webrtc.VideoSink import org.webrtc.audio.JavaAudioDeviceModule.AudioSamples -import stream.video.sfu.event.ReconnectDetails import stream.video.sfu.models.ClientCapability import stream.video.sfu.models.TrackType -import stream.video.sfu.models.VideoDimension import stream.video.sfu.models.WebsocketReconnectStrategy -import java.util.Collections -import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import kotlin.coroutines.resume @Deprecated( message = "No longer used internally. The reconnect deadline is now driven by the server's " + @@ -143,28 +111,6 @@ import kotlin.coroutines.resume ) const val sfuReconnectTimeoutMillis = 30_000 -/** - * Outcome of a single reconnect attempt. Each reconnect method returns one of - * these instead of throwing, making the control flow in the reconnect loop - * explicit and exhaustively checked by the compiler. - */ -private sealed class ReconnectOutcome { - /** Reconnect succeeded — exit the loop. */ - object Success : ReconnectOutcome() - - /** A required precondition is missing (no session, no location). Terminal — don't retry. */ - data class PreconditionNotMet(val reason: String) : ReconnectOutcome() - - /** Peer connections are stale and can't be reused. Should escalate to REJOIN. */ - object PeerConnectionStale : ReconnectOutcome() - - /** Server-initiated disconnect — leave the call cleanly. */ - object Disconnect : ReconnectOutcome() - - /** A transient failure occurred. The loop should retry with escalation. */ - data class Failed(val error: Exception) : ReconnectOutcome() -} - /** * The call class gives you access to all call level API calls * @@ -183,40 +129,69 @@ public class Call( val id: String, val user: User, ) { - internal var location: String? = null - private var subscriptions = Collections.synchronizedSet(mutableSetOf()) - - /** - * Increment this only for REJOIN and MIGRATION strategies - */ - internal var nonFastReconnectAttempts = 0 internal val clientImpl = client as StreamVideoClient internal val scopeProvider: ScopeProvider = ScopeProviderImpl(clientImpl.scope) - // Atomic controls - private var atomicLeave = AtomicUnitCall() - private val logger by taggedLogger("Call:$type:$id") private val supervisorJob = SupervisorJob() - private var callStatsReportingJob: Job? = null - private var powerManager: PowerManager? = null + internal var powerManager: PowerManager? = null internal val scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) + /** Delegate that owns the live RTC session state and reconnect bookkeeping. */ + private val sessionManager = CallSessionManager(this) + + /** Session handles all real time communication for video and audio */ + internal val session: MutableStateFlow get() = sessionManager.session + + var sessionId: String + get() = sessionManager.sessionId + set(value) { + sessionManager.sessionId = value + } + internal val unifiedSessionId: String get() = sessionManager.unifiedSessionId + + internal var location: String? + get() = sessionManager.location + set(value) { + sessionManager.location = value + } + + /** + * Increment this only for REJOIN and MIGRATION strategies + */ + internal var nonFastReconnectAttempts: Int + get() = sessionManager.nonFastReconnectAttempts + set(value) { + sessionManager.nonFastReconnectAttempts = value + } + + internal var connectStartTime: Long + get() = sessionManager.connectStartTime + set(value) { + sessionManager.connectStartTime = value + } + internal var reconnectStartTime: Long + get() = sessionManager.reconnectStartTime + set(value) { + sessionManager.reconnectStartTime = value + } + + /** Delegate that owns the event flow, subscriptions and event dispatch. */ + private val eventManager = CallEventManager(this) + // Must be initialized before `state` — CallState → SortedParticipantsState // launches a coroutine that reads `call.events` (leaking-this race). - val events = MutableSharedFlow(extraBufferCapacity = 150) + val events: MutableSharedFlow = eventManager.events /** The call state contains all state such as the participant list, reactions etc */ val state = CallState(client, this, user, scope) - private val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } - /** Camera gives you access to the local camera */ - val camera by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.camera } - val microphone by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.microphone } - val speaker by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.speaker } - val screenShare by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.screenShare } + val camera get() = mediaManager.camera + val microphone get() = mediaManager.microphone + val speaker get() = mediaManager.speaker + val screenShare get() = mediaManager.screenShare /** The cid is type:id */ val cid = "$type:$id" @@ -233,13 +208,6 @@ public class Call( // val monitor = CallHealthMonitor(this, scope, onIceRecoveryFailed) - private val soundInputProcessor = SoundInputProcessor(thresholdCrossedCallback = { - if (!microphone.isEnabled.value) { - state.markSpeakingAsMuted() - } - }) - private val audioLevelOutputHelper = RampValueUpAndDownHelper() - /** * This returns the local microphone volume level. The audio volume is a linear * value between 0 (no sound) and 1 (maximum volume). This is not a raw output - @@ -249,43 +217,22 @@ public class Call( * participant. * Note: Doesn't return any values until the session is established! */ - val localMicrophoneAudioLevel: StateFlow = audioLevelOutputHelper.currentLevel + val localMicrophoneAudioLevel: StateFlow get() = media.localMicrophoneAudioLevel /** * Contains stats events for observation. */ - val statsReport: MutableStateFlow = MutableStateFlow(null) + val statsReport: MutableStateFlow get() = statsReporter.statsReport /** * Contains stats history. */ - val statLatencyHistory: MutableStateFlow> = MutableStateFlow(listOf(0, 0, 0)) - - /** - * Time (in millis) when the full reconnection flow started. Will be null again once - * the reconnection flow ends (success or failure) - */ - private var sfuSocketReconnectionTime: Long? = null + val statLatencyHistory: MutableStateFlow> get() = statsReporter.statLatencyHistory /** * Call has been left and the object is cleaned up and destroyed. */ - private var isDestroyed = false - - /** Session handles all real time communication for video and audio */ - internal val session: MutableStateFlow = MutableStateFlow(null) - - var sessionId = UUID.randomUUID().toString() - internal val unifiedSessionId = UUID.randomUUID().toString() - - /** - * SFU IDs (edge names) we failed to connect to (e.g. SFU_FULL). Sent in migrating_from_list - * when requesting new credentials so the coordinator can exclude them. - */ - private val failedSfuIds: MutableSet = ConcurrentHashMap.newKeySet() - - internal var connectStartTime = 0L - internal var reconnectStartTime = 0L + internal val isDestroyed: Boolean get() = lifecycle.isDestroyed /** * EGL base context shared between peerConnectionFactory and mediaManager @@ -295,26 +242,10 @@ public class Call( EglBase.create() } - // peerConnectionFactory is nullable and recreated when audioBitrateProfile changes (before joining) - private var _peerConnectionFactory: StreamPeerConnectionFactory? = null - internal var peerConnectionFactory: StreamPeerConnectionFactory - get() { - if (_peerConnectionFactory == null) { - _peerConnectionFactory = StreamPeerConnectionFactory( - context = clientImpl.context, - audioProcessing = clientImpl.audioProcessing, - audioUsage = clientImpl.callServiceConfigRegistry.get(type).audioUsage, - audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, - audioBitrateProfileProvider = { mediaManager.microphone.audioBitrateProfile.value }, - sharedEglBaseProvider = { eglBase }, - webRtcLoggingLevel = clientImpl.loggingLevel.webRtcLoggingLevel, - ) - } - return _peerConnectionFactory!! - } + get() = media.peerConnectionFactory set(value) { - _peerConnectionFactory = value + media.peerConnectionFactory = value } internal val callAnalytics = @@ -329,6 +260,18 @@ public class Call( scope, ) + /** Delegate that wraps all coordinator (REST) API calls for this call. */ + private val apiClient by lazy { CallApiClient(this) } + + /** Delegate that periodically collects and reports WebRTC stats. */ + private val statsReporter by lazy { CallStatsReporter(this) } + + /** Delegate that binds video tracks to renderers and handles media-quality overrides. */ + private val callRenderer by lazy { CallRenderer(this) } + + /** Delegate that owns the peer-connection factory, media manager and audio pipeline. */ + private val media = CallMediaManager(this) + /** * Checks if the audioBitrateProfile has changed since the factory was created, * and recreates the factory if needed. This should only be called before joining. @@ -336,61 +279,19 @@ public class Call( * If the factory hasn't been created yet, it will be created with the current profile * when first accessed, so no recreation is needed. */ - internal fun ensureFactoryMatchesAudioProfile() { - val factory = _peerConnectionFactory - - // If factory hasn't been created yet, it will be created with current profile automatically - if (factory == null) { - return - } - - // Check if current profile differs from the profile used to create the factory - val factoryProfile = factory.audioBitrateProfile - val currentProfile = mediaManager.microphone.audioBitrateProfile.value - - if (factoryProfile != null && currentProfile != factoryProfile) { - logger.i { - "Audio bitrate profile changed from $factoryProfile to $currentProfile. " + - "Recreating factory before joining." - } - recreateFactoryAndAudioTracks() - } - } + internal fun ensureFactoryMatchesAudioProfile() = media.ensureFactoryMatchesAudioProfile() /** * Recreates peerConnectionFactory, audioSource, audioTrack, videoSource and videoTrack * with the current audioBitrateProfile. This should only be called before the call is joined. */ - internal fun recreateFactoryAndAudioTracks() { - val wasMicrophoneEnabled = microphone.status.value is DeviceStatus.Enabled - val wasCameraEnabled = camera.status.value is DeviceStatus.Enabled - - // Dispose all tracks and sources first - mediaManager.disposeTracksAndSources() - - // Recreate the factory (which will use the new audioBitrateProfile) - recreatePeerConnectionFactory() - - // Re-enable tracks if they were enabled - if (wasMicrophoneEnabled) { - // audioTrack will be recreated on next access, then we enable it - microphone.enable(fromUser = false) - } - if (wasCameraEnabled) { - // videoTrack will be recreated on next access, then we enable it - camera.enable(fromUser = false) - } - } + internal fun recreateFactoryAndAudioTracks() = media.recreateFactoryAndAudioTracks() /** * Recreates peerConnectionFactory with the current audioBitrateProfile. * This should only be called before the call is joined. */ - internal fun recreatePeerConnectionFactory() { - _peerConnectionFactory?.dispose() - _peerConnectionFactory = null - // Next access to peerConnectionFactory will recreate it with current profile - } + internal fun recreatePeerConnectionFactory() = media.recreatePeerConnectionFactory() internal val clientCapabilities = ConcurrentHashMap().apply { put( @@ -399,92 +300,52 @@ public class Call( ) } - internal val mediaManager by lazy { - if (testInstanceProvider.mediaManagerCreator != null) { - testInstanceProvider.mediaManagerCreator!!.invoke() - } else { - MediaManagerImpl( - clientImpl.context, - this, - scope, - eglBase.eglBaseContext, - clientImpl.callServiceConfigRegistry.get(type).audioUsage, - ) { clientImpl.callServiceConfigRegistry.get(type).audioUsage } - } - } + internal val mediaManager get() = media.mediaManager - private val listener = object : NetworkStateProvider.NetworkStateListener { - override suspend fun onConnected() { - leaveTimeoutAfterDisconnect?.cancel() + /** Delegate that reacts to device connectivity changes (reconnect / leave-on-timeout). */ + private val connectivityMonitor = CallConnectivityMonitor(this) - val elapsedTimeMils = System.currentTimeMillis() - lastDisconnect - logger.d { - "[NetworkStateListener#onConnected] #network; no args, elapsedTimeMils:$elapsedTimeMils, lastDisconnect:$lastDisconnect, reconnectDeadlineMils:$reconnectDeadlineMillis" - } - val strategy = if (lastDisconnect > 0 && elapsedTimeMils < reconnectDeadlineMillis) { - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST - } else { - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN - } - reconnect(strategy, "NetworkStateListener#onConnected") - } + /** Delegate that drives the join flow (permissions, retry loop, session creation). */ + private val joinCoordinator = CallJoinCoordinator(this) - override suspend fun onDisconnected() { - logger.d { - "[NetworkStateListener#onDisconnected] #network; old lastDisconnect:$lastDisconnect, clientImpl.leaveAfterDisconnectSeconds:${clientImpl.leaveAfterDisconnectSeconds}" - } - lastDisconnect = System.currentTimeMillis() - logger.d { - "[NetworkStateListener#onDisconnected] #network; new lastDisconnect:$lastDisconnect" - } - leaveTimeoutAfterDisconnect = scope.launch { - delay(clientImpl.leaveAfterDisconnectSeconds * 1000) - val conn = state.connection.value - if (conn is RealtimeConnection.Connected) { - logger.d { - "[NetworkStateListener#onDisconnected] #network; Already reconnected ($conn) — not leaving" - } - return@launch - } - val message = "Leaving after being disconnected for ${clientImpl.leaveAfterDisconnectSeconds}" - logger.d { - "[NetworkStateListener#onDisconnected] #network; Leaving after being disconnected for ${clientImpl.leaveAfterDisconnectSeconds} (connection=$conn)" - } - leave(CallLeaveReason.Backend(cause = BackendCause.LEAVE_TIMEOUT_AFTER_DISCONNECT, message = message)) - } - logger.d { "[NetworkStateListener#onDisconnected] #network; at $lastDisconnect" } - } + internal var reconnectDeadlineMillis: Int = 10_000 + + /** Delegate that owns the unified reconnect state machine (fast / rejoin / migrate). */ + private val reconnector = CallReconnector(this) + + /** Delegate that owns leave / end / cleanup teardown and the destroyed flag. */ + private val lifecycle = CallLifecycleManager(this) + + /** Returns whether the device currently has network connectivity. */ + internal fun isNetworkConnected(): Boolean = connectivityMonitor.isConnected() + + /** Stops the ICE and connectivity monitors (used during teardown). */ + internal fun stopConnectionMonitors() { + iceMonitor.stop() + connectivityMonitor.cancelLeaveTimeout() + connectivityMonitor.unsubscribe() } - private var leaveTimeoutAfterDisconnect: Job? = null - private var lastDisconnect = 0L - private var reconnectDeadlineMillis: Int = 10_000 - private val reconnectMutex = Mutex() + /** Stops periodic WebRTC stats reporting (used during teardown). */ + internal fun stopStatsReporting() { + statsReporter.stop() + } - private var monitorPublisherPCStateJob: Job? = null - private var monitorSubscriberPCStateJob: Job? = null private var sfuListener: Job? = null private var sfuEvents: Job? = null + /** Delegate that restarts ICE when the publisher/subscriber connections drop. */ + private val iceMonitor = CallIceConnectionMonitor(this) + init { - scope.launch { - soundInputProcessor.currentAudioLevel.collect { - audioLevelOutputHelper.rampToValue(it) - } - } + media.startAudioLevelMonitoring() powerManager = safeCallWithDefault(null) { clientImpl.context.getSystemService(POWER_SERVICE) as? PowerManager } } /** Basic crud operations */ - suspend fun get(): Result { - val response = clientImpl.getCall(type, id) - response.onSuccess { - state.updateFromResponse(it) - } - return response - } + suspend fun get(): Result = apiClient.get() /** Create a call. You can create a call client side, many apps prefer to do this server side though */ suspend fun create( @@ -497,67 +358,24 @@ public class Call( ring: Boolean = false, notify: Boolean = false, video: Boolean? = null, - ): Result { - val response = if (members != null) { - clientImpl.getOrCreateCallFullMembers( - type = type, - id = id, - members = members, - custom = custom, - settingsOverride = settings, - startsAt = startsAt, - team = team, - ring = ring, - notify = notify, - video = video, - ) - } else { - clientImpl.getOrCreateCall( - type = type, - id = id, - memberIds = memberIds, - custom = custom, - settingsOverride = settings, - startsAt = startsAt, - team = team, - ring = ring, - notify = notify, - video = video, - ) - } - - response.onSuccess { - /** - * Because [CallState.updateFromResponse] reads the value of [ClientState.ringingCall] - */ - if (ring) { - client.state._ringingCall.value = this - } - state.updateFromResponse(it) - if (ring) { - client.state.addRingingCall(this, RingingState.Outgoing()) - } - } - return response - } + ): Result = apiClient.create( + memberIds = memberIds, + members = members, + custom = custom, + settings = settings, + startsAt = startsAt, + team = team, + ring = ring, + notify = notify, + video = video, + ) /** Update a call */ suspend fun update( custom: Map? = null, settingsOverride: CallSettingsRequest? = null, startsAt: OffsetDateTime? = null, - ): Result { - val request = UpdateCallRequest( - custom = custom, - settingsOverride = settingsOverride, - startsAt = startsAt, - ) - val response = clientImpl.updateCall(type, id, request) - response.onSuccess { - state.updateFromResponse(it) - } - return response - } + ): Result = apiClient.update(custom, settingsOverride, startsAt) suspend fun join( create: Boolean = false, @@ -588,70 +406,14 @@ public class Call( // if we are a guest user, make sure we wait for the token before running the join flow clientImpl.guestUserJob?.await() - // Ensure factory is created with the current audioBitrateProfile before joining - ensureFactoryMatchesAudioProfile() - - this.state.callJoinInterceptor = callJoinInterceptor - - // the join flow should retry up to 3 times - // if the error is not permanent - // and fail immediately on permanent errors - state._connection.value = RealtimeConnection.InProgress - var retryCount = 0 - - var result: Result - - atomicLeave = AtomicUnitCall() - while (retryCount < 3) { - 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 - // the settings during a call. - val settings = state.settings.value - if (settings != null) { - updateMediaManagerFromSettings(settings) - } else { - logger.w { - "[join] Call settings were null - this should never happen after a call" + - "is joined. MediaManager will not be initialised with server settings." - } - } - return result - } - if (result is Failure) { - session.value = null - 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 - } - } - delay((retryCount - 1) * 1000L) - } - 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 joinCoordinator.join( + create, + createOptions, + ring, + notify, + hintHighScaleLivestreamPublisher, + callJoinInterceptor, ) - return Failure(value = Error.GenericError(errorMessage)) } suspend fun joinAndRing( @@ -659,40 +421,14 @@ public class Call( createOptions: CreateCallOptions? = CreateCallOptions(members), video: Boolean = isVideoEnabled(), callJoinInterceptor: CallJoinInterceptor? = null, - ): Result { - logger.d { "[joinAndRing] #ringing; #track; members: $members, video: $video" } - state.toggleJoinAndRingProgress(true) - return join( - ring = false, - createOptions = createOptions, - callJoinInterceptor = callJoinInterceptor, - ).flatMap { rtcSession -> - logger.d { "[joinAndRing] Joined #ringing; #track; ring: $members" } - ring(RingCallRequest(isVideoEnabled(), members)).map { - logger.d { "[joinAndRing] Ringed #ringing; #track; ring: $members" } - clientImpl.state._ringingCall.value = this - rtcSession - }.onError { - logger.e { "[joinAndRing] Ring failed #ringing; #track; error: $it" } - state.toggleJoinAndRingProgress(false) - leave( - CallLeaveReason.Backend( - BackendCause.RING_FAILED, - message = "ring-failed (${it.message})", - ), - ) - } - } - } + ): Result = joinCoordinator.joinAndRing( + members, + createOptions, + video, + callJoinInterceptor, + ) - internal fun isPermanentError(error: Any): Boolean { - if (error is Error.ThrowableError) { - if (error.message.contains("Unable to resolve host")) { - return false - } - } - return true - } + internal fun isPermanentError(error: Any): Boolean = joinCoordinator.isPermanentError(error) internal suspend fun _join( create: Boolean = false, @@ -701,97 +437,32 @@ public class Call( notify: Boolean = false, hintHighScaleLivestreamPublisher: Boolean? = null, joinAnalyticsModel: JoinAnalyticsModel, - ): Result { - nonFastReconnectAttempts = 0 + ): Result = joinCoordinator.joinInternal( + create, + createOptions, + ring, + notify, + hintHighScaleLivestreamPublisher, + joinAnalyticsModel, + ) + + /** Cancels the SFU socket observers (signal WS + fast-reconnect deadline listener). */ + internal fun cancelSfuObservers() { sfuEvents?.cancel() sfuListener?.cancel() + } - if (session.value != null) { - return Failure(Error.GenericError("Call $cid has already been joined")) - } - logger.d { - "[joinInternal] #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" - } - - connectStartTime = System.currentTimeMillis() - - // step 1. call the join endpoint to get a list of SFUs - val locationResult = clientImpl.getCachedLocation() - if (locationResult !is Success) { - return locationResult as Failure - } - location = locationResult.value - - val options = createOptions - ?: if (create) { - CreateCallOptions() - } else { - null - } - val result = - joinRequest( - options, - locationResult.value, - ring = ring, - notify = notify, - hintHighScaleLivestreamPublisher = hintHighScaleLivestreamPublisher, - joinAnalyticsModel = joinAnalyticsModel, - ) - - if (result !is Success) { - return result as Failure - } - val sfuToken = result.value.credentials.token - val sfuUrl = result.value.credentials.server.url - val sfuWsUrl = result.value.credentials.server.wsEndpoint - val sfuName = result.value.credentials.server.edgeName - val iceServers = result.value.credentials.iceServers.map { it.toIceServer() } - val localSession = if (testInstanceProvider.rtcSessionCreator != null) { - testInstanceProvider.rtcSessionCreator!!.invoke() - } else { - RtcSession( - sessionId = this.sessionId, - apiKey = clientImpl.apiKey, - lifecycle = clientImpl.coordinatorConnectionModule.lifecycle, - client = client, - call = this, - sfuUrl = sfuUrl, - sfuWsUrl = sfuWsUrl, - sfuToken = sfuToken, - sfuName = sfuName, - remoteIceServers = iceServers, - powerManager = powerManager, - sfuAnalytics = callAnalytics.sfuAnalytics.apply { - sfuAnalyticsStateHolder.updateSfuId( - sfuName, - ) - }, - ) - } - session.value = localSession - - session.value?.let { - state._connection.value = RealtimeConnection.Joined(it) - } + /** Resets the leave guard so a fresh join can run after a previous leave. */ + internal fun resetLeaveGuard() = lifecycle.resetLeaveGuard() - when (val result = session.value?.connectInternal()) { - is SfuConnectionResult.Connected -> Unit - is SfuConnectionResult.Failed -> - return Failure( - Error.GenericError(result.error.message ?: "RtcSession error occurred."), - ) - null -> - return Failure(Error.GenericError("RtcSession was null during connect")) - } - client.state.setActiveCall(this) - monitorSession(result.value) - return Success(value = session.value!!) - } + /** Applies server-provided call settings to the local media manager. */ + internal fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) = + media.updateMediaManagerFromSettings(callSettings) - private fun Call.monitorSession(result: JoinCallResponse) { + internal fun monitorSession(result: JoinCallResponse) { sfuEvents?.cancel() sfuListener?.cancel() - startCallStatsReporting(result.statsOptions.reportingIntervalMs.toLong()) + statsReporter.start(result.statsOptions.reportingIntervalMs.toLong()) // listen to Signal WS sfuEvents = scope.launch { session.value?.let { @@ -803,617 +474,56 @@ public class Call( } } } - monitorPublisherPCStateJob?.cancel() callAnalytics.peerConnectionAnalytics.stopAndObservePeerConnections(session) callAnalytics.audioAnalytics.observeFirstRemoteParticipantAudioMuteState( session, state.participants, ) - monitorPublisherPCStateJob = scope.launch { - session - .filterNotNull() - .flatMapLatest { it.publisher.filterNotNull() } - .flatMapLatest { publisher -> - publisher.iceState.map { publisher to it } - } - .collect { (publisher, state) -> - when (state) { - PeerConnection.IceConnectionState.FAILED, - PeerConnection.IceConnectionState.DISCONNECTED, - -> { - publisher.connection.restartIce() - } - else -> { - logger.d { "[monitorPubConnectionState] Ice connection state is $state" } - } - } - } - } - - monitorSubscriberPCStateJob?.cancel() - monitorSubscriberPCStateJob = scope.launch { - session.value?.subscriber?.value?.iceState?.collect { - when (it) { - PeerConnection.IceConnectionState.FAILED, PeerConnection.IceConnectionState.DISCONNECTED -> { - session.value?.requestSubscriberIceRestart() - } - - else -> { - logger.d { "[monitorSubConnectionState] Ice connection state is $it" } - } - } - } - } - network.subscribe(listener) + iceMonitor.start() + connectivityMonitor.subscribe() } - private fun startCallStatsReporting(reportingIntervalMs: Long = 10_000) { - callStatsReportingJob?.cancel() - callStatsReportingJob = scope.launch { - // Wait a bit before we start capturing stats - delay(reportingIntervalMs) - - while (isActive) { - delay(reportingIntervalMs) - session.value?.sendCallStats( - report = collectStats(), - ) - } - } - } - - internal suspend fun collectStats(): CallStatsReport { - val publisherStats = runCatching { session.value?.getPublisherStats() }.getOrNull() - val subscriberStats = runCatching { session.value?.getSubscriberStats() }.getOrNull() - runCatching { - state.stats.updateFromRTCStats(publisherStats, isPublisher = true) - state.stats.updateFromRTCStats(subscriberStats, isPublisher = false) - state.stats.updateLocalStats() - }.onFailure { logger.w { "[collectStats] Failed to update stats: ${it.message}" } } - val local = state.stats._local.value - - val report = CallStatsReport( - publisher = publisherStats, - subscriber = subscriberStats, - local = local, - stateStats = state.stats, - ) - - statsReport.value = report - statLatencyHistory.value += report.stateStats.publisher.latency.value - if (statLatencyHistory.value.size > 20) { - statLatencyHistory.value = statLatencyHistory.value.takeLast(20) - } - - return report - } + internal suspend fun collectStats(): CallStatsReport = statsReporter.collectStats() // region Reconnection — unified loop /** - * Unified reconnection entry point. - * - * All callers (stateJob, NetworkStateListener, error handlers) funnel through - * this method. It acquires [reconnectMutex] so only one flow runs at a time, - * and implements a retry loop that **escalates** the strategy on failure: - * - * - **FAST** → **REJOIN** after [MAX_FAST_RECONNECT_ATTEMPTS] failures *or* - * when the elapsed time exceeds [reconnectDeadlineMillis]. - * - **MIGRATE** → **REJOIN** if the migration attempt fails. - * - **DISCONNECT** → leaves the call (server-initiated). - * - **UNSPECIFIED** → treated as FAST (HealthMonitor already attempted the WS). - * - * The loop exits when the connection state becomes [RealtimeConnection.Connected], - * [RealtimeConnection.ReconnectingFailed], or [RealtimeConnection.Disconnected]. - * - * @param strategy the initial reconnection strategy requested by the caller. - * @param reason a human-readable reason for logging / tracing. + * Unified reconnection entry point. Delegates to [CallReconnector], which owns the + * FAST / REJOIN / MIGRATE state machine and the single-flight reconnect mutex. */ internal suspend fun reconnect( strategy: WebsocketReconnectStrategy, reason: String, - ) { - val conn = state.connection.value - logger.d { "[reconnect] Entry — strategy=$strategy reason=$reason connection=$conn" } - - if (isDestroyed || conn is RealtimeConnection.Disconnected) { - logger.d { - "[reconnect] Call already left/destroyed (isDestroyed=$isDestroyed, conn=$conn) — skipping ($reason)" - } - return - } - - // Use tryLock so concurrent triggers (stateJob, NetworkStateListener, - // SfuSocket errors) don't queue up. If a reconnect loop is already - // running it will handle recovery; redundant callers return immediately. - if (!reconnectMutex.tryLock()) { - logger.d { "[reconnect] Active reconnect loop running — skipping ($reason)" } - return - } - var currentStrategy = strategy - try { - // Re-check after acquiring the lock — bail only if the user left. - // We deliberately allow reconnect from Connected (SFU/network may - // request it) and from ReconnectingFailed (fresh trigger like - // network recovery should be retried). - val currentConn = state.connection.value - if (currentConn is RealtimeConnection.Disconnected) { - logger.d { "[reconnect] State is $currentConn — no reconnect needed ($reason)" } - return - } - - val isMigrate = strategy == - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE - state._connection.value = if (isMigrate) { - RealtimeConnection.Migrating - } else { - RealtimeConnection.Reconnecting - } - - val loopStartTime = System.currentTimeMillis() - // Local iteration counter for this reconnect() invocation only. - // Controls MAX_RECONNECT_ATTEMPTS cap and FAST→REJOIN escalation. - // Distinct from the class-level reconnectAttempts which is cumulative. - var loopIteration = 0 - - while (true) { - // EARLY EXIT CASE 1 - State based - val connectionState = state.connection.value - if (connectionState is RealtimeConnection.Connected || - connectionState is RealtimeConnection.ReconnectingFailed || - connectionState is RealtimeConnection.Disconnected - ) { - logger.i { "[reconnect] Loop finished — state=$connectionState" } - break - } - - // EARLY EXIT CASE 2 - count based - if (loopIteration >= MAX_RECONNECT_ATTEMPTS) { - logger.w { "[reconnect] Max reconnect attempts ($MAX_RECONNECT_ATTEMPTS) reached — giving up" } - state._connection.value = RealtimeConnection.ReconnectingFailed - break - } - - // EARLY EXIT CASE 3 - time based - val elapsedMs = System.currentTimeMillis() - loopStartTime - if (clientImpl.leaveAfterDisconnectSeconds > 0 && - elapsedMs / 1000 > clientImpl.leaveAfterDisconnectSeconds - ) { - logger.w { "[reconnect] Disconnection timeout reached — giving up" } - state._connection.value = RealtimeConnection.ReconnectingFailed - break - } - - // Wait for network before doing anything else. Polls without - // consuming the attempt budget — the elapsed-time guard below - // will still fire if we wait too long. - if (!network.isConnected()) { - logger.d { - "[reconnect] Network unavailable — waiting for connectivity (loopIteration=$loopIteration)" - } - delay(RECONNECT_DELAY_MS) - continue - } - - val currentTimeInMillis = System.currentTimeMillis() - if (currentTimeInMillis - loopStartTime >= reconnectDeadlineMillis) { - currentStrategy = when (currentStrategy) { - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_UNSPECIFIED, - -> { - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN - } - - else -> currentStrategy - } - } - - logger.i { - "[reconnect] loopIteration=$loopIteration strategy=$currentStrategy reason=$reason" - } - - val outcome = when (currentStrategy) { - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_UNSPECIFIED, - -> reconnectFast(reason) - - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN -> { - nonFastReconnectAttempts++ - reconnectRejoin( - reason, - JoinAnalyticsModel(nonFastReconnectAttempts, JoinReason.ReJoin), - ) - } - - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE -> { - nonFastReconnectAttempts++ - reconnectMigrate( - JoinAnalyticsModel(nonFastReconnectAttempts, JoinReason.Migrate), - ) - } - - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_DISCONNECT -> - ReconnectOutcome.Disconnect - } - - when (outcome) { - is ReconnectOutcome.Success -> break - - is ReconnectOutcome.Disconnect -> { - logger.w { "[reconnect] DISCONNECT requested — leaving call" } - leave( - CallLeaveReason.Backend(BackendCause.SFU_DISCONNECT), - ) - break - } - - is ReconnectOutcome.PreconditionNotMet -> { - logger.w { "[reconnect] Precondition not met — giving up: ${outcome.reason}" } - state._connection.value = RealtimeConnection.ReconnectingFailed - break - } - - is ReconnectOutcome.PeerConnectionStale -> { - logger.w { "[reconnect] Peer connections stale — escalating to REJOIN" } - delay(RECONNECT_DELAY_MS) - loopIteration++ - currentStrategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN - } - - is ReconnectOutcome.Failed -> { - logger.w { - "[reconnect] $currentStrategy ($nonFastReconnectAttempts) failed: ${outcome.error.message}" - } - - delay(RECONNECT_DELAY_MS) - loopIteration++ - - val wasMigrating = currentStrategy == - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE - val pastFastReconnectDeadline = (System.currentTimeMillis() - loopStartTime) > - reconnectDeadlineMillis - val shouldEscalateToRejoin = wasMigrating || - pastFastReconnectDeadline - - if (shouldEscalateToRejoin) { - currentStrategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN - } - logger.i { "[reconnect] Next strategy: $currentStrategy (loopIteration=$loopIteration)" } - } - } - } - - if (state.connection.value is RealtimeConnection.ReconnectingFailed) { - 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", - message, - ), - ) - } - } finally { - // Always release the mutex — even on exceptions or coroutine - // cancellation — so future reconnect() calls aren't permanently blocked. - reconnectMutex.unlock() - logger.d { - "[reconnect] Free reconnectMutex, initialStrategy: $strategy, finalStrategy: $currentStrategy" - } - } - } - - /** - * Fast reconnect to the same SFU with the same participant session. - * Reuses the existing session ID — no previous_session_id needed since the - * SFU already knows this participant. - */ - private suspend fun reconnectFast(reason: String): ReconnectOutcome { - logger.d { "[reconnectFast] reconnectAttempts=$nonFastReconnectAttempts" } - val currentSession = session.value - ?: return ReconnectOutcome.PreconditionNotMet("No active session for fast reconnect") - - val stats = collectStats() - currentSession.sendCallStats(stats) - - currentSession.prepareReconnect() - state._connection.value = RealtimeConnection.Reconnecting - reconnectStartTime = System.currentTimeMillis() - - val (_, subscriptionsInfo, publishingInfo) = currentSession.currentSfuInfo() - val reconnectDetails = ReconnectDetails( - previous_session_id = "", - strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, - announced_tracks = publishingInfo, - subscriptions = subscriptionsInfo, - reconnect_attempt = nonFastReconnectAttempts, - reason = reason, - ) - return when (val result = currentSession.fastReconnect(reconnectDetails)) { - is FastReconnectResult.Connected -> ReconnectOutcome.Success - is FastReconnectResult.PeerConnectionStale -> ReconnectOutcome.PeerConnectionStale - is FastReconnectResult.Failed -> ReconnectOutcome.Failed(result.error) - } - } - - /** - * Rejoin a call. Creates a new session ID and joins as a new participant. - * 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, - joinAnalyticsModel: JoinAnalyticsModel, - ): ReconnectOutcome { - logger.d { "[reconnectRejoin] reconnectAttempts=$nonFastReconnectAttempts" } - state._connection.value = RealtimeConnection.Reconnecting - val loc = location - ?: return ReconnectOutcome.PreconditionNotMet("No location available for rejoin") - val oldSession = session.value - ?: return ReconnectOutcome.PreconditionNotMet("No active session for rejoin") - reconnectStartTime = System.currentTimeMillis() - - val joinResponse = joinRequest(location = loc, joinAnalyticsModel = joinAnalyticsModel) - if (joinResponse !is Success) { - return ReconnectOutcome.Failed( - Exception("Failed to get join response: ${joinResponse.errorOrNull()}"), - ) - } - - val cred = joinResponse.value.credentials - val currentOptions = oldSession.publisher.value?.currentOptions() - logger.i { "Rejoin SFU ${oldSession.sfuUrl} to ${cred.server.url}" } - - this.sessionId = UUID.randomUUID().toString() - val (prevSessionId, subscriptionsInfo, publishingInfo) = oldSession.currentSfuInfo() - val reconnectDetails = ReconnectDetails( - previous_session_id = prevSessionId, - strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, - announced_tracks = publishingInfo, - subscriptions = subscriptionsInfo, - reconnect_attempt = nonFastReconnectAttempts, - reason = reason, - ) - this.state.removeParticipant(prevSessionId) - oldSession.prepareRejoin("rejoin") - val newSession = RtcSession( - clientImpl, - nonFastReconnectAttempts, - powerManager, - this, - sessionId, - clientImpl.apiKey, - clientImpl.coordinatorConnectionModule.lifecycle, - cred.server.url, - cred.server.wsEndpoint, - 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, - JoinAnalyticsModel(joinAnalyticsModel.retryAttempt), - ) - ) { - is SfuConnectionResult.Connected -> { - newSession.sfuTracer.trace("rejoin", reason) - monitorSession(joinResponse.value) - ReconnectOutcome.Success - } - is SfuConnectionResult.Failed -> ReconnectOutcome.Failed(result.error) - } - } - - /** - * 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(joinAnalyticsModel: JoinAnalyticsModel): ReconnectOutcome { - logger.d { "[reconnectMigrate] Migrating" } - state._connection.value = RealtimeConnection.Migrating - val loc = location - ?: return ReconnectOutcome.PreconditionNotMet("No location available for migrate") - val oldSession = session.value - ?: return ReconnectOutcome.PreconditionNotMet("No active session for migrate") - reconnectStartTime = System.currentTimeMillis() - addFailedSfuId(oldSession.sfuName) - - val joinResponse = - joinRequest( - location = loc, - migratingFrom = oldSession.sfuName, - joinAnalyticsModel = joinAnalyticsModel, - ) - if (joinResponse !is Success) { - return ReconnectOutcome.Failed( - Exception( - "Failed to get join response during migration: ${joinResponse.errorOrNull()}", - ), - ) - } - - val cred = joinResponse.value.credentials - val currentOptions = oldSession.publisher.value?.currentOptions() - val oldSfuName = oldSession.sfuName - logger.i { "[reconnectMigrate] Migrate SFU $oldSfuName to ${cred.server.edgeName}" } - - val (_, subscriptionsInfo, publishingInfo) = oldSession.currentSfuInfo() - val reconnectDetails = ReconnectDetails( - previous_session_id = "", - strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE, - announced_tracks = publishingInfo, - subscriptions = subscriptionsInfo, - from_sfu_id = oldSfuName, - reconnect_attempt = nonFastReconnectAttempts, - ) - - val stats = collectStats() - oldSession.sendCallStats(stats) - oldSession.enterMigration() - - val newSession = RtcSession( - clientImpl, - nonFastReconnectAttempts, - powerManager, - this, - sessionId, - clientImpl.apiKey, - clientImpl.coordinatorConnectionModule.lifecycle, - cred.server.url, - cred.server.wsEndpoint, - 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, - JoinAnalyticsModel(joinAnalyticsModel.retryAttempt), - ) - when (result) { - is SfuConnectionResult.Connected -> { - monitorSession(joinResponse.value) - ReconnectOutcome.Success - } - is SfuConnectionResult.Failed -> ReconnectOutcome.Failed(result.error) - } - } finally { - oldSession.finalizeMigration() - } - } + ) = reconnector.reconnect(strategy, reason) // Keep public wrappers for backward compatibility and Debug class - suspend fun fastReconnect(reason: String = "unknown") { - reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, reason) - } + suspend fun fastReconnect(reason: String = "unknown") = reconnector.fastReconnect(reason) - suspend fun rejoin(reason: String = "unknown") { - reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, reason) - } + suspend fun rejoin(reason: String = "unknown") = reconnector.rejoin(reason) - suspend fun migrate() { - reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE, "migrate") - } + suspend fun migrate() = reconnector.migrate() // endregion @InternalStreamVideoApi - fun leave(reason: CallLeaveReason) { - logger.d { "[leave] #ringing; call_cid:$cid" } - internalLeave(reason) - } - - fun leave(reason: String = "user") { - logger.d { "[leave] #ringing; no args, call_cid:$cid" } - internalLeave(CallLeaveReason.Custom(reason)) - } - - private fun internalLeave(reason: CallLeaveReason) = atomicLeave { - monitorSubscriberPCStateJob?.cancel() - monitorPublisherPCStateJob?.cancel() - callAnalytics.stopObservers() - monitorPublisherPCStateJob = null - monitorSubscriberPCStateJob = null - leaveTimeoutAfterDisconnect?.cancel() - network.unsubscribe(listener) - sfuListener?.cancel() - sfuEvents?.cancel() - state._connection.value = RealtimeConnection.Disconnected - logger.v { "[leave] #ringing; call_id = $id" } - if (isDestroyed) { - logger.w { "[leave] #ringing; Call already destroyed, ignoring" } - return@atomicLeave - } - isDestroyed = true - - sfuSocketReconnectionTime = null - - /** - * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) - */ - stopScreenSharing() - camera.disable() - microphone.disable() - - if (id == client.state.activeCall.value?.id) { - client.state.removeActiveCall(this) // Will also stop CallService - } - - if (id == client.state.ringingCall.value?.id) { - client.state.removeRingingCall(this) - } - - TelecomCallController(client.context) - .leaveCall(this) + fun leave(reason: CallLeaveReason) = lifecycle.leave(reason) - (client as StreamVideoClient).onCallCleanUp(this) - - 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() - session.value?.sendCallStats(stats) - } - // Must complete before cleanup() cancels the session's supervisor job. - safeCall { session.value?.sendLeaveEvent(leaveReason) } - cleanup() - } - } + fun leave(reason: String = "user") = lifecycle.leave(reason) /** ends the call for yourself as well as other users */ - suspend fun end(): Result { - // end the call for everyone - val result = clientImpl.endCall(type, id) - // cleanup - leave( - CallLeaveReason.SdkDriven( - cause = SdkCause.END_CALL, - message = "CALL_ENDED", // Call ended by local user - ), - ) - return result - } + suspend fun end(): Result = lifecycle.end() - suspend fun pinForEveryone(sessionId: String, userId: String): Result { - return clientImpl.pinForEveryone(type, id, sessionId, userId) - } + suspend fun pinForEveryone(sessionId: String, userId: String): Result = + apiClient.pinForEveryone(sessionId, userId) - suspend fun unpinForEveryone(sessionId: String, userId: String): Result { - return clientImpl.unpinForEveryone(type, id, sessionId, userId) - } + suspend fun unpinForEveryone(sessionId: String, userId: String): Result = + apiClient.unpinForEveryone(sessionId, userId) suspend fun sendReaction( type: String, emoji: String? = null, custom: Map? = null, - ): Result { - return clientImpl.sendReaction(this.type, id, type, emoji, custom) - } + ): Result = apiClient.sendReaction(type, emoji, custom) suspend fun queryMembers( filter: Map, @@ -1421,49 +531,20 @@ public class Call( limit: Int = 25, prev: String? = null, next: String? = null, - ): Result { - return clientImpl.queryMembersInternal( - type = type, - id = id, - filter = filter, - sort = sort, - prev = prev, - next = next, - limit = limit, - ).onSuccess { state.updateFromResponse(it) }.map { it.toQueriedMembers() } - } + ): Result = apiClient.queryMembers(filter, sort, limit, prev, next) suspend fun muteAllUsers( audio: Boolean = true, video: Boolean = false, screenShare: Boolean = false, - ): Result { - val request = MuteUsersData( - muteAllUsers = true, - audio = audio, - video = video, - screenShare = screenShare, - ) - return clientImpl.muteUsers(type, id, request) - } + ): Result = apiClient.muteAllUsers(audio, video, screenShare) fun setVisibility( sessionId: String, trackType: TrackType, visible: Boolean, viewportId: String = sessionId, - ) { - logger.i { - "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" - } - session.value?.updateTrackDimensions( - sessionId, - trackType, - visible, - Subscriber.defaultVideoDimension, - viewportId, - ) - } + ) = callRenderer.setVisibility(sessionId, trackType, visible, viewportId) fun setVisibility( sessionId: String, @@ -1472,29 +553,9 @@ public class Call( viewportId: String = sessionId, width: Int, height: Int, - ) { - logger.i { - "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" - } - session.value?.updateTrackDimensions( - sessionId, - trackType, - visible, - VideoDimension(width, height), - viewportId, - ) - } - - fun handleEvent(event: VideoEvent) { - logger.v { "[call handleEvent] #sfu; event.type: ${event.getEventType()}" } + ) = callRenderer.setVisibility(sessionId, trackType, visible, viewportId, width, height) - when (event) { - is GoAwayEvent -> - scope.launch { - migrate() - } - } - } + fun handleEvent(event: VideoEvent) = eventManager.handleEvent(event) // TODO: review this /** @@ -1510,70 +571,7 @@ public class Call( trackType: TrackType, onRendered: (VideoTextureViewRenderer) -> Unit = {}, viewportId: String = sessionId, - ) { - logger.d { "[initRenderer] #sfu; #track; sessionId: $sessionId" } - - // Note this comes from the shared eglBase - videoRenderer.init( - eglBase.eglBaseContext, - object : RendererCommon.RendererEvents { - override fun onFirstFrameRendered() { - val width = videoRenderer.measuredWidth - val height = videoRenderer.measuredHeight - logger.i { - "[initRenderer.onFirstFrameRendered] #sfu; #track; " + - "trackType: $trackType, dimension: ($width - $height), " + - "sessionId: $sessionId" - } - if (trackType != TrackType.TRACK_TYPE_SCREEN_SHARE) { - session.value?.updateTrackDimensions( - sessionId, - trackType, - true, - VideoDimension(width, height), - viewportId, - ) - } - onRendered(videoRenderer) - callAnalytics.videoAnalytics.firstVideoFrameRendered( - - trackType, - width, - height, - rtcSession = session.value, - sessionId, - this@Call.sessionId, - ) - } - - override fun onFrameResolutionChanged( - videoWidth: Int, - videoHeight: Int, - rotation: Int, - ) { - val width = videoRenderer.measuredWidth - val height = videoRenderer.measuredHeight - logger.v { - "[initRenderer.onFrameResolutionChanged] #sfu; #track; " + - "trackType: $trackType, " + - "viewport size: ($width - $height), " + - "video size: ($videoWidth - $videoHeight), " + - "sessionId: $sessionId" - } - - if (trackType != TrackType.TRACK_TYPE_SCREEN_SHARE) { - session.value?.updateTrackDimensions( - sessionId, - trackType, - true, - VideoDimension(width, height), - viewportId, - ) - } - } - }, - ) - } + ) = callRenderer.initRenderer(videoRenderer, sessionId, trackType, onRendered, viewportId) /** * Enables the provided client capabilities. @@ -1597,48 +595,29 @@ public class Call( startHls: Boolean = false, startRecording: Boolean = false, startTranscription: Boolean = false, - ): Result { - val result = clientImpl.goLive( - type = type, - id = id, - startHls = startHls, - startRecording = startRecording, - startTranscription = startTranscription, - ) - result.onSuccess { state.updateFromResponse(it) } + ): Result = apiClient.goLive(startHls, startRecording, startTranscription) - return result - } + suspend fun stopLive(): Result = apiClient.stopLive() - suspend fun stopLive(): Result { - val result = clientImpl.stopLive(type, id) - result.onSuccess { state.updateFromResponse(it) } - return result - } - - suspend fun sendCustomEvent(data: Map): Result { - return clientImpl.sendCustomEvent(this.type, this.id, data) - } + suspend fun sendCustomEvent(data: Map): Result = + apiClient.sendCustomEvent(data) /** Permissions */ - suspend fun requestPermissions(vararg permission: String): Result { - return clientImpl.requestPermissions(type, id, permission.toList()) - } + suspend fun requestPermissions(vararg permission: String): Result = + apiClient.requestPermissions(*permission) suspend fun startRecording(): Result { return startRecording(RecordingType.Composite) } - suspend fun startRecording(recordingType: RecordingType): Result { - return clientImpl.startRecording(type, id, recordingType = recordingType) - } + suspend fun startRecording(recordingType: RecordingType): Result = + apiClient.startRecording(recordingType) suspend fun stopRecording(): Result { return stopRecording(RecordingType.Composite) } - suspend fun stopRecording(recordingType: RecordingType): Result { - return clientImpl.stopRecording(type, id, recordingType) - } + suspend fun stopRecording(recordingType: RecordingType): Result = + apiClient.stopRecording(recordingType) /** * User needs to have [OwnCapability.Screenshare] capability in order to start screen @@ -1652,41 +631,18 @@ public class Call( fun startScreenSharing( mediaProjectionPermissionResultData: Intent, includeAudio: Boolean = false, - ) { - if (state.ownCapabilities.value.contains(OwnCapability.Screenshare)) { - session.value?.setScreenShareTrack() - screenShare.enable(mediaProjectionPermissionResultData, includeAudio = includeAudio) - } else { - logger.w { "Can't start screen sharing - user doesn't have wnCapability.Screenshare permission" } - } - } + ) = media.startScreenSharing(mediaProjectionPermissionResultData, includeAudio) - fun stopScreenSharing() { - screenShare.disable(fromUser = true) - } + fun stopScreenSharing() = media.stopScreenSharing() - suspend fun startHLS(): Result { - return clientImpl.startBroadcasting(type, id) - .onSuccess { - state.updateFromResponse(it) - } - } + suspend fun startHLS(): Result = apiClient.startHLS() - suspend fun stopHLS(): Result { - return clientImpl.stopBroadcasting(type, id) - } + suspend fun stopHLS(): Result = apiClient.stopHLS() public fun subscribeFor( vararg eventTypes: Class, listener: VideoEventListener, - ): EventSubscription = synchronized(subscriptions) { - val filter = { event: VideoEvent -> - eventTypes.any { type -> type.isInstance(event) } - } - val sub = EventSubscription(listener, filter) - subscriptions.add(sub) - return sub - } + ): EventSubscription = eventManager.subscribeFor(*eventTypes, listener = listener) @Deprecated( level = DeprecationLevel.WARNING, @@ -1695,153 +651,46 @@ public class Call( ) public fun subscribe( listener: VideoEventListener, - ): EventSubscription = synchronized(subscriptions) { - val sub = EventSubscription(listener) - subscriptions.add(sub) - return sub - } + ): EventSubscription = eventManager.subscribe(listener) @Deprecated( level = DeprecationLevel.WARNING, message = "Deprecated in favor of the `events` flow.", replaceWith = ReplaceWith("events.collect { }"), ) - public fun unsubscribe(eventSubscription: EventSubscription) = synchronized(subscriptions) { - subscriptions.remove(eventSubscription) - } + public fun unsubscribe(eventSubscription: EventSubscription) = + eventManager.unsubscribe(eventSubscription) - public suspend fun blockUser(userId: String): Result { - return clientImpl.blockUser(type, id, userId) - } + public suspend fun blockUser(userId: String): Result = + apiClient.blockUser(userId) // TODO: add removeMember (single) - public suspend fun removeMembers(userIds: List): Result { - val request = UpdateCallMembersRequest(removeMembers = userIds) - return clientImpl.updateMembers(type, id, request) - } + public suspend fun removeMembers(userIds: List): Result = + apiClient.removeMembers(userIds) public suspend fun grantPermissions( userId: String, permissions: List, - ): Result { - val request = UpdateUserPermissionsData( - userId = userId, - grantedPermissions = permissions, - ) - return clientImpl.updateUserPermissions(type, id, request) - } + ): Result = apiClient.grantPermissions(userId, permissions) public suspend fun revokePermissions( userId: String, permissions: List, - ): Result { - val request = UpdateUserPermissionsData( - userId = userId, - revokedPermissions = permissions, - ) - return clientImpl.updateUserPermissions(type, id, request) - } - - public suspend fun updateMembers(memberRequests: List): Result { - val request = UpdateCallMembersRequest(updateMembers = memberRequests) - return clientImpl.updateMembers(type, id, request) - } + ): Result = apiClient.revokePermissions(userId, permissions) - fun fireEvent(event: VideoEvent) = synchronized(subscriptions) { - subscriptions.forEach { sub -> - if (!sub.isDisposed) { - // subs without filters should always fire - if (sub.filter == null) { - sub.listener.onEvent(event) - } - - // if there is a filter, check it and fire if it matches - sub.filter?.let { - if (it.invoke(event)) { - sub.listener.onEvent(event) - } - } - } - } - - if (!events.tryEmit(event)) { - logger.e { "Failed to emit event to observers: [event: $event]" } - } - } - - private fun monitorHeadset() { - microphone.devices.onEach { availableDevices -> - logger.d { - "[monitorHeadset] new available devices, prev selected: ${microphone.nonHeadsetFallbackDevice}" - } - - val bluetoothHeadset = - availableDevices.find { it is StreamAudioDevice.BluetoothHeadset } - val wiredHeadset = availableDevices.find { it is StreamAudioDevice.WiredHeadset } - - if (bluetoothHeadset != null) { - logger.d { "[monitorHeadset] BT headset selected" } - microphone.select(bluetoothHeadset) - } else if (wiredHeadset != null) { - logger.d { "[monitorHeadset] wired headset found" } - microphone.select(wiredHeadset) - } else { - logger.d { "[monitorHeadset] no headset found" } - - microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> - logger.d { "[monitorHeadset] before device selected" } - microphone.select(deviceBeforeHeadset) - } - } - }.launchIn(scope) - } - - private fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) { - // Speaker - if (speaker.status.value is DeviceStatus.NotSelected) { - val enableSpeaker = - if (callSettings.video.cameraDefaultOn || camera.status.value is DeviceStatus.Enabled) { - // if camera is enabled then enable speaker. Eventually this should - // be a new audio.defaultDevice setting returned from backend - true - } else { - callSettings.audio.defaultDevice == AudioSettingsResponse.DefaultDevice.Speaker || - callSettings.audio.speakerDefaultOn - } - - speaker.setEnabled(enabled = enableSpeaker) - } - - monitorHeadset() + public suspend fun updateMembers(memberRequests: List): Result = + apiClient.updateMembers(memberRequests) - // Camera - if (camera.status.value is DeviceStatus.NotSelected) { - val defaultDirection = - if (callSettings.video.cameraFacing == VideoSettingsResponse.CameraFacing.Front) { - CameraDirection.Front - } else { - CameraDirection.Back - } - camera.setDirection(defaultDirection) - camera.setEnabled(callSettings.video.cameraDefaultOn) - } - - // Mic - if (microphone.status.value == DeviceStatus.NotSelected) { - val enabled = callSettings.audio.micDefaultOn - microphone.setEnabled(enabled) - } - } + fun fireEvent(event: VideoEvent) = eventManager.fireEvent(event) /** * List the recordings for this call. * * @param sessionId - if session ID is supplied, only recordings for that session will be loaded. */ - suspend fun listRecordings(sessionId: String? = null): Result { - return clientImpl.listRecordings(type, id, sessionId) - } + suspend fun listRecordings(sessionId: String? = null): Result = + apiClient.listRecordings(sessionId) /** * Kick a user from the call. @@ -1852,65 +701,31 @@ public class Call( suspend fun kickUser( userId: String, block: Boolean = false, - ): Result = clientImpl.kickUser( - type, - id, - userId, - block, - ) + ): Result = apiClient.kickUser(userId, block) suspend fun muteUser( userId: String, audio: Boolean = true, video: Boolean = false, screenShare: Boolean = false, - ): Result { - val request = MuteUsersData( - users = listOf(userId), - muteAllUsers = false, - audio = audio, - video = video, - screenShare = screenShare, - ) - return clientImpl.muteUsers(type, id, request) - } + ): Result = apiClient.muteUser(userId, audio, video, screenShare) suspend fun muteUsers( userIds: List, audio: Boolean = true, video: Boolean = false, screenShare: Boolean = false, - ): Result { - val request = MuteUsersData( - users = userIds, - muteAllUsers = false, - audio = audio, - video = video, - screenShare = screenShare, - ) - return clientImpl.muteUsers(type, id, request) - } - - /** Adds the given SFU ID (edge name) to the failed set (for migrating_from_list). */ - private fun addFailedSfuId(sfuId: String) { - if (sfuId.isBlank()) return - failedSfuIds.add(sfuId) - } + ): Result = apiClient.muteUsers(userIds, audio, video, screenShare) /** Returns a snapshot of failed SFU IDs to send as migrating_from_list. */ - private fun getFailedSfuIdsSnapshot(): List = failedSfuIds.toList() - - /** Clears the failed SFU list (e.g. after a successful join). */ - private fun clearFailedSfuIds() { - failedSfuIds.clear() - } + internal fun getFailedSfuIdsSnapshot(): List = reconnector.getFailedSfuIdsSnapshot() /** * Called by [RtcSession] when connection to the SFU is established successfully. * Clears the failed SFU list so we don't exclude this SFU on future requests. */ internal fun onSfuConnectionEstablished() { - clearFailedSfuIds() + reconnector.clearFailedSfuIds() } @VisibleForTesting @@ -1923,48 +738,21 @@ public class Call( 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, - members = create?.memberRequestsFromIds(), - custom = create?.custom, - settingsOverride = create?.settings, - startsAt = create?.startsAt, - team = create?.team, - ring = ring, - notify = notify, - location = location, - migratingFrom = migratingFrom, - migratingFromList = migratingFromList, - hintHighScaleLivestreamPublisher = hintHighScaleLivestreamPublisher, - ) - result.onSuccess { - callAnalytics.joinAnalytics.onJoinRequestSuccess( - joinAnalyticsModel, - it.call.currentSessionId, - ) - state.updateFromResponse(it) - } - return result - } + ): Result = joinCoordinator.joinRequest( + create, + location, + migratingFrom, + migratingFromList, + ring, + notify, + hintHighScaleLivestreamPublisher, + joinAnalyticsModel, + ) - fun cleanup() { - // monitor.stop() - state.cleanup() - session.value?.cleanup() - shutDownJobsGracefully() - callStatsReportingJob?.cancel() - mediaManager.cleanup() // TODO Rahul, Verify Later: need to check which call has owned the media at the moment(probably use active call) - session.value = null - // Cleanup the call's scope provider - scopeProvider.cleanup() - } + fun cleanup() = lifecycle.cleanup() // This will allow the Rest APIs to be executed which are in queue before leave - private fun shutDownJobsGracefully() { + internal fun shutDownJobsGracefully() { UserScope(ClientScope()).launch { supervisorJob.children.forEach { it.join() } supervisorJob.cancel() @@ -1972,38 +760,22 @@ public class Call( scope.cancel() } - suspend fun ring(): Result { - logger.d { "[ring] #ringing; no args" } - return clientImpl.ring(type, id) - } - - suspend fun ring(ringCallRequest: RingCallRequest): Result { - logger.d { "[ring] #ringing ringCallRequest: $ringCallRequest" } - return clientImpl.ring(type, id, ringCallRequest) - } + suspend fun ring(): Result = apiClient.ring() - suspend fun notify(): Result { - logger.d { "[notify] #ringing; no args" } - return clientImpl.notify(type, id) - } + suspend fun ring(ringCallRequest: RingCallRequest): Result = + apiClient.ring(ringCallRequest) - suspend fun accept(): Result { - logger.d { "[accept] #ringing; no args, call_id:$id" } - state.acceptedOnThisDevice = true + suspend fun notify(): Result = apiClient.notify() - clientImpl.state.transitionToAcceptCall(this) - return clientImpl.accept(type, id) - } + suspend fun accept(): Result = apiClient.accept() /** * Should outlive both the call scope and the service scope and needs to be executed in the client-level scope. * Because the call scope or service scope may be cancelled or finished while the network request is still in flight * TODO: Run this in clientImpl.scope internally */ - suspend fun reject(reason: RejectReason? = null): Result { - logger.d { "[reject] #ringing; rejectReason: $reason, call_id:$id" } - return clientImpl.reject(type, id, reason) - } + suspend fun reject(reason: RejectReason? = null): Result = + apiClient.reject(reason) // For debugging internal suspend fun reject( @@ -2014,51 +786,15 @@ public class Call( return reject(reason) } - fun processAudioSample(audioSample: AudioSamples) { - soundInputProcessor.processSoundInput(audioSample.data) - } + fun processAudioSample(audioSample: AudioSamples) = media.processAudioSample(audioSample) fun collectUserFeedback( rating: Int, reason: String? = null, custom: Map? = null, - ) { - scope.launch { - clientImpl.collectFeedback( - callType = type, - id = id, - sessionId = sessionId, - rating = rating, - reason = reason, - custom = custom, - ) - } - } - - suspend fun takeScreenshot(track: VideoTrack): Bitmap? { - return suspendCancellableCoroutine { continuation -> - var screenshotSink: VideoSink? = null - screenshotSink = VideoSink { - // make sure we stop after first frame is delivered - if (!continuation.isActive) { - return@VideoSink - } - it.retain() - val bitmap = YuvFrame.bitmapFromVideoFrame(it) - it.release() - - // This has to be launched asynchronously - removing the sink on the - // same thread as the videoframe is delivered will lead to a deadlock - // (needs investigation why) - scope.launch { - track.video.removeSink(screenshotSink) - } - continuation.resume(bitmap) - } + ) = apiClient.collectUserFeedback(rating, reason, custom) - track.video.addSink(screenshotSink) - } - } + suspend fun takeScreenshot(track: VideoTrack): Bitmap? = callRenderer.takeScreenshot(track) fun isPinnedParticipant(sessionId: String): Boolean = state.pinnedParticipants.value.containsKey( @@ -2082,37 +818,26 @@ public class Call( return state.settings.value?.video?.enabled ?: false } - fun isAudioProcessingEnabled(): Boolean { - return peerConnectionFactory.isAudioProcessingEnabled() - } + fun isAudioProcessingEnabled(): Boolean = media.isAudioProcessingEnabled() - fun setAudioProcessingEnabled(enabled: Boolean) { - return peerConnectionFactory.setAudioProcessingEnabled(enabled) - } + fun setAudioProcessingEnabled(enabled: Boolean) = media.setAudioProcessingEnabled(enabled) - fun toggleAudioProcessing(): Boolean { - return peerConnectionFactory.toggleAudioProcessing() - } + fun toggleAudioProcessing(): Boolean = media.toggleAudioProcessing() - suspend fun startTranscription(): Result { - return clientImpl.startTranscription(type, id) - } + suspend fun startTranscription(): Result = + apiClient.startTranscription() - suspend fun stopTranscription(): Result { - return clientImpl.stopTranscription(type, id) - } + suspend fun stopTranscription(): Result = + apiClient.stopTranscription() - suspend fun listTranscription(): Result { - return clientImpl.listTranscription(type, id) - } + suspend fun listTranscription(): Result = + apiClient.listTranscription() - suspend fun startClosedCaptions(): Result { - return clientImpl.startClosedCaptions(type, id) - } + suspend fun startClosedCaptions(): Result = + apiClient.startClosedCaptions() - suspend fun stopClosedCaptions(): Result { - return clientImpl.stopClosedCaptions(type, id) - } + suspend fun stopClosedCaptions(): Result = + apiClient.stopClosedCaptions() fun updateClosedCaptionsSettings(closedCaptionsSettings: ClosedCaptionsSettings) { state.closedCaptionManager.updateClosedCaptionsSettings(closedCaptionsSettings) @@ -2127,14 +852,7 @@ public class Call( fun setPreferredIncomingVideoResolution( resolution: PreferredVideoResolution?, sessionIds: List? = null, - ) { - session.value?.let { session -> - session.trackOverridesHandler.updateOverrides( - sessionIds = sessionIds, - dimensions = resolution?.let { VideoDimension(it.width, it.height) }, - ) - } - } + ) = callRenderer.setPreferredIncomingVideoResolution(resolution, sessionIds) /** * Enables/disables incoming video feed. @@ -2142,9 +860,8 @@ public class Call( * @param enabled Whether the video feed should be enabled or disabled. Set to `null` to switch back to auto. * @param sessionIds The participant session IDs to enable/disable the video feed for. If `null`, the setting will be applied to all participants. */ - fun setIncomingVideoEnabled(enabled: Boolean?, sessionIds: List? = null) { - session.value?.trackOverridesHandler?.updateOverrides(sessionIds, visible = enabled) - } + fun setIncomingVideoEnabled(enabled: Boolean?, sessionIds: List? = null) = + callRenderer.setIncomingVideoEnabled(enabled, sessionIds) /** * Enables or disables the reception of incoming audio tracks for all or specified participants. @@ -2157,18 +874,8 @@ public class Call( * @param sessionIds Optional list of participant session IDs for which to toggle incoming audio. * If `null`, the audio setting is applied to all participants currently in the session. */ - fun setIncomingAudioEnabled(enabled: Boolean, sessionIds: List? = null) { - val participantTrackMap = session.value?.subscriber?.value?.tracks ?: return - - val targetTracks = when { - sessionIds != null -> sessionIds.mapNotNull { participantTrackMap[it] } - else -> participantTrackMap.values.toList() - } - - targetTracks - .mapNotNull { it[TrackType.TRACK_TYPE_AUDIO] as? AudioTrack } - .forEach { it.enableAudio(enabled) } - } + fun setIncomingAudioEnabled(enabled: Boolean, sessionIds: List? = null) = + callRenderer.setIncomingAudioEnabled(enabled, sessionIds) @InternalStreamVideoApi public val debug = Debug(this) @@ -2216,23 +923,6 @@ public class Call( } companion object { - /** How many consecutive FAST reconnect failures are allowed before - * escalating to a full REJOIN. Kept small because each failed FAST - * attempt can cost up to DEFAULT_SOCKET_TIMEOUT (10 s) waiting for - * the WebSocket handshake to time out. */ - private const val MAX_FAST_RECONNECT_ATTEMPTS = 3 - - /** Absolute upper bound on loop iterations across all strategies - * (FAST + REJOIN + MIGRATE combined). Prevents infinite retries - * when every strategy keeps failing. */ - private const val MAX_RECONNECT_ATTEMPTS = 10 - - /** Delay between consecutive reconnect attempts (both after a - * failed attempt and while polling for network availability - * during FAST reconnect). Kept short so the SDK reacts quickly - * once conditions improve. */ - private const val RECONNECT_DELAY_MS = 500L - internal var testInstanceProvider = TestInstanceProvider() internal class TestInstanceProvider { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt new file mode 100644 index 00000000000..80c73cc4717 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt @@ -0,0 +1,402 @@ +/* + * 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.call.components + +import io.getstream.android.video.generated.models.AcceptCallResponse +import io.getstream.android.video.generated.models.BlockUserResponse +import io.getstream.android.video.generated.models.CallSettingsRequest +import io.getstream.android.video.generated.models.GetCallResponse +import io.getstream.android.video.generated.models.GetOrCreateCallResponse +import io.getstream.android.video.generated.models.GoLiveResponse +import io.getstream.android.video.generated.models.KickUserResponse +import io.getstream.android.video.generated.models.ListRecordingsResponse +import io.getstream.android.video.generated.models.ListTranscriptionsResponse +import io.getstream.android.video.generated.models.MemberRequest +import io.getstream.android.video.generated.models.MuteUsersResponse +import io.getstream.android.video.generated.models.PinResponse +import io.getstream.android.video.generated.models.RejectCallResponse +import io.getstream.android.video.generated.models.RingCallRequest +import io.getstream.android.video.generated.models.RingCallResponse +import io.getstream.android.video.generated.models.SendCallEventResponse +import io.getstream.android.video.generated.models.SendReactionResponse +import io.getstream.android.video.generated.models.StartClosedCaptionsResponse +import io.getstream.android.video.generated.models.StartTranscriptionResponse +import io.getstream.android.video.generated.models.StopClosedCaptionsResponse +import io.getstream.android.video.generated.models.StopLiveResponse +import io.getstream.android.video.generated.models.StopTranscriptionResponse +import io.getstream.android.video.generated.models.UnpinResponse +import io.getstream.android.video.generated.models.UpdateCallMembersRequest +import io.getstream.android.video.generated.models.UpdateCallMembersResponse +import io.getstream.android.video.generated.models.UpdateCallRequest +import io.getstream.android.video.generated.models.UpdateCallResponse +import io.getstream.android.video.generated.models.UpdateUserPermissionsResponse +import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.model.MuteUsersData +import io.getstream.video.android.core.model.QueriedMembers +import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.core.model.SortField +import io.getstream.video.android.core.model.UpdateUserPermissionsData +import io.getstream.video.android.core.recording.RecordingType +import io.getstream.video.android.core.utils.toQueriedMembers +import kotlinx.coroutines.launch +import org.threeten.bp.OffsetDateTime + +/** + * Wraps all coordinator (REST) API calls for a [Call]. Each method delegates to the + * coordinator client and, where relevant, updates [Call.state] from the response. + * + * This component holds no mutable call state — it is a stateless façade over the + * coordinator endpoints, extracted from [Call] to keep the public class focused. + */ +internal class CallApiClient( + private val call: Call, +) { + private val logger by taggedLogger("Call:ApiClient:${call.type}:${call.id}") + + private val clientImpl get() = call.clientImpl + private val type get() = call.type + private val id get() = call.id + private val state get() = call.state + + suspend fun get(): Result { + val response = clientImpl.getCall(type, id) + response.onSuccess { + state.updateFromResponse(it) + } + return response + } + + suspend fun create( + memberIds: List? = null, + members: List? = null, + custom: Map? = null, + settings: CallSettingsRequest? = null, + startsAt: OffsetDateTime? = null, + team: String? = null, + ring: Boolean = false, + notify: Boolean = false, + video: Boolean? = null, + ): Result { + val response = if (members != null) { + clientImpl.getOrCreateCallFullMembers( + type = type, + id = id, + members = members, + custom = custom, + settingsOverride = settings, + startsAt = startsAt, + team = team, + ring = ring, + notify = notify, + video = video, + ) + } else { + clientImpl.getOrCreateCall( + type = type, + id = id, + memberIds = memberIds, + custom = custom, + settingsOverride = settings, + startsAt = startsAt, + team = team, + ring = ring, + notify = notify, + video = video, + ) + } + + response.onSuccess { + /** + * Because [io.getstream.video.android.core.CallState.updateFromResponse] reads the + * value of [io.getstream.video.android.core.ClientState.ringingCall] + */ + if (ring) { + call.client.state._ringingCall.value = call + } + state.updateFromResponse(it) + if (ring) { + call.client.state.addRingingCall(call, RingingState.Outgoing()) + } + } + return response + } + + suspend fun update( + custom: Map? = null, + settingsOverride: CallSettingsRequest? = null, + startsAt: OffsetDateTime? = null, + ): Result { + val request = UpdateCallRequest( + custom = custom, + settingsOverride = settingsOverride, + startsAt = startsAt, + ) + val response = clientImpl.updateCall(type, id, request) + response.onSuccess { + state.updateFromResponse(it) + } + return response + } + + suspend fun pinForEveryone(sessionId: String, userId: String): Result { + return clientImpl.pinForEveryone(type, id, sessionId, userId) + } + + suspend fun unpinForEveryone(sessionId: String, userId: String): Result { + return clientImpl.unpinForEveryone(type, id, sessionId, userId) + } + + suspend fun sendReaction( + type: String, + emoji: String? = null, + custom: Map? = null, + ): Result { + return clientImpl.sendReaction(this.type, id, type, emoji, custom) + } + + suspend fun queryMembers( + filter: Map, + sort: List = mutableListOf(SortField.Desc("created_at")), + limit: Int = 25, + prev: String? = null, + next: String? = null, + ): Result { + return clientImpl.queryMembersInternal( + type = type, + id = id, + filter = filter, + sort = sort, + prev = prev, + next = next, + limit = limit, + ).onSuccess { state.updateFromResponse(it) }.map { it.toQueriedMembers() } + } + + suspend fun muteAllUsers( + audio: Boolean = true, + video: Boolean = false, + screenShare: Boolean = false, + ): Result { + val request = MuteUsersData( + muteAllUsers = true, + audio = audio, + video = video, + screenShare = screenShare, + ) + return clientImpl.muteUsers(type, id, request) + } + + suspend fun goLive( + startHls: Boolean = false, + startRecording: Boolean = false, + startTranscription: Boolean = false, + ): Result { + val result = clientImpl.goLive( + type = type, + id = id, + startHls = startHls, + startRecording = startRecording, + startTranscription = startTranscription, + ) + result.onSuccess { state.updateFromResponse(it) } + + return result + } + + suspend fun stopLive(): Result { + val result = clientImpl.stopLive(type, id) + result.onSuccess { state.updateFromResponse(it) } + return result + } + + suspend fun sendCustomEvent(data: Map): Result { + return clientImpl.sendCustomEvent(type, id, data) + } + + suspend fun requestPermissions(vararg permission: String): Result { + return clientImpl.requestPermissions(type, id, permission.toList()) + } + + suspend fun startRecording(recordingType: RecordingType): Result { + return clientImpl.startRecording(type, id, recordingType = recordingType) + } + + suspend fun stopRecording(recordingType: RecordingType): Result { + return clientImpl.stopRecording(type, id, recordingType) + } + + suspend fun startHLS(): Result { + return clientImpl.startBroadcasting(type, id) + .onSuccess { + state.updateFromResponse(it) + } + } + + suspend fun stopHLS(): Result { + return clientImpl.stopBroadcasting(type, id) + } + + suspend fun blockUser(userId: String): Result { + return clientImpl.blockUser(type, id, userId) + } + + suspend fun removeMembers(userIds: List): Result { + val request = UpdateCallMembersRequest(removeMembers = userIds) + return clientImpl.updateMembers(type, id, request) + } + + suspend fun grantPermissions( + userId: String, + permissions: List, + ): Result { + val request = UpdateUserPermissionsData( + userId = userId, + grantedPermissions = permissions, + ) + return clientImpl.updateUserPermissions(type, id, request) + } + + suspend fun revokePermissions( + userId: String, + permissions: List, + ): Result { + val request = UpdateUserPermissionsData( + userId = userId, + revokedPermissions = permissions, + ) + return clientImpl.updateUserPermissions(type, id, request) + } + + suspend fun updateMembers(memberRequests: List): Result { + val request = UpdateCallMembersRequest(updateMembers = memberRequests) + return clientImpl.updateMembers(type, id, request) + } + + suspend fun listRecordings(sessionId: String? = null): Result { + return clientImpl.listRecordings(type, id, sessionId) + } + + suspend fun kickUser( + userId: String, + block: Boolean = false, + ): Result = clientImpl.kickUser( + type, + id, + userId, + block, + ) + + suspend fun muteUser( + userId: String, + audio: Boolean = true, + video: Boolean = false, + screenShare: Boolean = false, + ): Result { + val request = MuteUsersData( + users = listOf(userId), + muteAllUsers = false, + audio = audio, + video = video, + screenShare = screenShare, + ) + return clientImpl.muteUsers(type, id, request) + } + + suspend fun muteUsers( + userIds: List, + audio: Boolean = true, + video: Boolean = false, + screenShare: Boolean = false, + ): Result { + val request = MuteUsersData( + users = userIds, + muteAllUsers = false, + audio = audio, + video = video, + screenShare = screenShare, + ) + return clientImpl.muteUsers(type, id, request) + } + + suspend fun ring(): Result { + logger.d { "[ring] #ringing; no args" } + return clientImpl.ring(type, id) + } + + suspend fun ring(ringCallRequest: RingCallRequest): Result { + logger.d { "[ring] #ringing ringCallRequest: $ringCallRequest" } + return clientImpl.ring(type, id, ringCallRequest) + } + + suspend fun notify(): Result { + logger.d { "[notify] #ringing; no args" } + return clientImpl.notify(type, id) + } + + suspend fun accept(): Result { + logger.d { "[accept] #ringing; no args, call_id:$id" } + state.acceptedOnThisDevice = true + + clientImpl.state.transitionToAcceptCall(call) + return clientImpl.accept(type, id) + } + + suspend fun reject(reason: RejectReason? = null): Result { + logger.d { "[reject] #ringing; rejectReason: $reason, call_id:$id" } + return clientImpl.reject(type, id, reason) + } + + fun collectUserFeedback( + rating: Int, + reason: String? = null, + custom: Map? = null, + ) { + call.scope.launch { + clientImpl.collectFeedback( + callType = type, + id = id, + sessionId = call.sessionId, + rating = rating, + reason = reason, + custom = custom, + ) + } + } + + suspend fun startTranscription(): Result { + return clientImpl.startTranscription(type, id) + } + + suspend fun stopTranscription(): Result { + return clientImpl.stopTranscription(type, id) + } + + suspend fun listTranscription(): Result { + return clientImpl.listTranscription(type, id) + } + + suspend fun startClosedCaptions(): Result { + return clientImpl.startClosedCaptions(type, id) + } + + suspend fun stopClosedCaptions(): Result { + return clientImpl.stopClosedCaptions(type, id) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt new file mode 100644 index 00000000000..14b8dc5ee69 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt @@ -0,0 +1,106 @@ +/* + * 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.call.components + +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.BackendCause +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.internal.network.NetworkStateProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import stream.video.sfu.models.WebsocketReconnectStrategy + +/** + * Observes device network connectivity for a [Call] and drives the call's response: + * triggering a fast/rejoin reconnect when connectivity returns, and leaving the call if + * the device stays offline past the configured `leaveAfterDisconnectSeconds`. + * + * Also owns the call's subscription to the underlying [NetworkStateProvider]. + */ +internal class CallConnectivityMonitor( + private val call: Call, +) { + private val logger by taggedLogger("Call:ConnectivityMonitor:${call.type}:${call.id}") + + private val clientImpl get() = call.clientImpl + + private val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } + + private var leaveTimeoutAfterDisconnect: Job? = null + private var lastDisconnect = 0L + + private val listener = object : NetworkStateProvider.NetworkStateListener { + override suspend fun onConnected() { + leaveTimeoutAfterDisconnect?.cancel() + + val elapsedTimeMils = System.currentTimeMillis() - lastDisconnect + logger.d { + "[NetworkStateListener#onConnected] #network; no args, elapsedTimeMils:$elapsedTimeMils, lastDisconnect:$lastDisconnect, reconnectDeadlineMils:${call.reconnectDeadlineMillis}" + } + val strategy = if (lastDisconnect > 0 && elapsedTimeMils < call.reconnectDeadlineMillis) { + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST + } else { + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN + } + call.reconnect(strategy, "NetworkStateListener#onConnected") + } + + override suspend fun onDisconnected() { + logger.d { + "[NetworkStateListener#onDisconnected] #network; old lastDisconnect:$lastDisconnect, clientImpl.leaveAfterDisconnectSeconds:${clientImpl.leaveAfterDisconnectSeconds}" + } + lastDisconnect = System.currentTimeMillis() + logger.d { + "[NetworkStateListener#onDisconnected] #network; new lastDisconnect:$lastDisconnect" + } + leaveTimeoutAfterDisconnect = call.scope.launch { + delay(clientImpl.leaveAfterDisconnectSeconds * 1000) + val conn = call.state.connection.value + if (conn is RealtimeConnection.Connected) { + logger.d { + "[NetworkStateListener#onDisconnected] #network; Already reconnected ($conn) — not leaving" + } + return@launch + } + val message = "Leaving after being disconnected for ${clientImpl.leaveAfterDisconnectSeconds}" + logger.d { + "[NetworkStateListener#onDisconnected] #network; Leaving after being disconnected for ${clientImpl.leaveAfterDisconnectSeconds} (connection=$conn)" + } + call.leave( + CallLeaveReason.Backend( + cause = BackendCause.LEAVE_TIMEOUT_AFTER_DISCONNECT, + message = message, + ), + ) + } + logger.d { "[NetworkStateListener#onDisconnected] #network; at $lastDisconnect" } + } + } + + fun subscribe() = network.subscribe(listener) + + fun unsubscribe() = network.unsubscribe(listener) + + fun isConnected(): Boolean = network.isConnected() + + fun cancelLeaveTimeout() { + leaveTimeoutAfterDisconnect?.cancel() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt new file mode 100644 index 00000000000..ab7ef57c4f6 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt @@ -0,0 +1,98 @@ +/* + * 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.call.components + +import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.EventSubscription +import io.getstream.video.android.core.events.GoAwayEvent +import io.getstream.video.android.core.events.VideoEventListener +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import java.util.Collections + +/** + * Owns the event pipeline for a [Call]: the shared [events] flow, the set of legacy + * [EventSubscription]s, and the dispatch / handling of incoming [VideoEvent]s. + */ +internal class CallEventManager( + private val call: Call, +) { + private val logger by taggedLogger("Call:EventManager:${call.type}:${call.id}") + + val events = MutableSharedFlow(extraBufferCapacity = 150) + + private val subscriptions = Collections.synchronizedSet(mutableSetOf()) + + fun subscribeFor( + vararg eventTypes: Class, + listener: VideoEventListener, + ): EventSubscription = synchronized(subscriptions) { + val filter = { event: VideoEvent -> + eventTypes.any { type -> type.isInstance(event) } + } + val sub = EventSubscription(listener, filter) + subscriptions.add(sub) + return sub + } + + fun subscribe( + listener: VideoEventListener, + ): EventSubscription = synchronized(subscriptions) { + val sub = EventSubscription(listener) + subscriptions.add(sub) + return sub + } + + fun unsubscribe(eventSubscription: EventSubscription) = synchronized(subscriptions) { + subscriptions.remove(eventSubscription) + } + + fun handleEvent(event: VideoEvent) { + logger.v { "[call handleEvent] #sfu; event.type: ${event.getEventType()}" } + + when (event) { + is GoAwayEvent -> + call.scope.launch { + call.migrate() + } + } + } + + fun fireEvent(event: VideoEvent) = synchronized(subscriptions) { + subscriptions.forEach { sub -> + if (!sub.isDisposed) { + // subs without filters should always fire + if (sub.filter == null) { + sub.listener.onEvent(event) + } + + // if there is a filter, check it and fire if it matches + sub.filter?.let { + if (it.invoke(event)) { + sub.listener.onEvent(event) + } + } + } + } + + if (!events.tryEmit(event)) { + logger.e { "Failed to emit event to observers: [event: $event]" } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt new file mode 100644 index 00000000000..038633843e3 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt @@ -0,0 +1,92 @@ +/* + * 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.call.components + +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.webrtc.PeerConnection + +/** + * Watches the publisher and subscriber peer-connection ICE states for a [Call] and + * triggers an ICE restart whenever a connection FAILS or becomes DISCONNECTED. + */ +internal class CallIceConnectionMonitor( + private val call: Call, +) { + private val logger by taggedLogger("Call:IceMonitor:${call.type}:${call.id}") + + private var monitorPublisherPCStateJob: Job? = null + private var monitorSubscriberPCStateJob: Job? = null + + fun start() { + startPublisherMonitor() + startSubscriberMonitor() + } + + private fun startPublisherMonitor() { + monitorPublisherPCStateJob?.cancel() + monitorPublisherPCStateJob = call.scope.launch { + call.session + .filterNotNull() + .flatMapLatest { it.publisher.filterNotNull() } + .flatMapLatest { publisher -> + publisher.iceState.map { publisher to it } + } + .collect { (publisher, state) -> + when (state) { + PeerConnection.IceConnectionState.FAILED, + PeerConnection.IceConnectionState.DISCONNECTED, + -> { + publisher.connection.restartIce() + } + else -> { + logger.d { "[monitorPubConnectionState] Ice connection state is $state" } + } + } + } + } + } + + private fun startSubscriberMonitor() { + monitorSubscriberPCStateJob?.cancel() + monitorSubscriberPCStateJob = call.scope.launch { + call.session.value?.subscriber?.value?.iceState?.collect { + when (it) { + PeerConnection.IceConnectionState.FAILED, PeerConnection.IceConnectionState.DISCONNECTED -> { + call.session.value?.requestSubscriberIceRestart() + } + + else -> { + logger.d { "[monitorSubConnectionState] Ice connection state is $it" } + } + } + } + } + } + + fun stop() { + monitorSubscriberPCStateJob?.cancel() + monitorPublisherPCStateJob?.cancel() + monitorPublisherPCStateJob = null + monitorSubscriberPCStateJob = null + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt new file mode 100644 index 00000000000..cdd5b62ff2e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -0,0 +1,322 @@ +/* + * 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.call.components + +import io.getstream.android.video.generated.models.JoinCallResponse +import io.getstream.android.video.generated.models.RingCallRequest +import io.getstream.log.taggedLogger +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.result.flatMap +import io.getstream.video.android.core.BackendCause +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallJoinInterceptor +import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.CreateCallOptions +import io.getstream.video.android.core.RealtimeConnection +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.call.RtcSession +import io.getstream.video.android.core.call.SfuConnectionResult +import io.getstream.video.android.core.model.toIceServer +import kotlinx.coroutines.delay + +/** + * Drives the join flow for a [Call]: permission checks, the bounded retry loop, the + * underlying join request to the coordinator, and creation + connection of the [RtcSession]. + */ +internal class CallJoinCoordinator( + private val call: Call, +) { + private val logger by taggedLogger("Call:JoinCoordinator:${call.type}:${call.id}") + + private val clientImpl get() = call.clientImpl + private val state get() = call.state + private val session get() = call.session + private val callAnalytics get() = call.callAnalytics + private val type get() = call.type + private val id get() = call.id + + suspend fun join( + create: Boolean = false, + createOptions: CreateCallOptions? = null, + ring: Boolean = false, + notify: Boolean = false, + 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" + } + val permissionPass = + clientImpl.permissionCheck.checkAndroidPermissionsGroup(clientImpl.context, call) + // Check android permissions and log a warning to make sure developers requested adequate permissions prior to using the call. + if (!permissionPass.first) { + logger.w { + "\n[Call.join()] called without having the required permissions.\n" + + "This will work only if you have [runForegroundServiceForCalls = false] in the StreamVideoBuilder.\n" + + "The reason is that [Call.join()] will by default start an ongoing call foreground service,\n" + + "To start this service and send the appropriate audio/video tracks the permissions are required,\n" + + "otherwise the service will fail to start, resulting in a crash.\n" + + "You can re-define your permissions and their expected state by overriding the [permissionCheck] in [StreamVideoBuilder]\n" + } + } + // if we are a guest user, make sure we wait for the token before running the join flow + clientImpl.guestUserJob?.await() + + // Ensure factory is created with the current audioBitrateProfile before joining + call.ensureFactoryMatchesAudioProfile() + + state.callJoinInterceptor = callJoinInterceptor + + // the join flow should retry up to 3 times + // if the error is not permanent + // and fail immediately on permanent errors + state._connection.value = RealtimeConnection.InProgress + var retryCount = 0 + + var result: Result + + call.resetLeaveGuard() + while (retryCount < 3) { + result = joinInternal( + 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 + // the settings during a call. + val settings = state.settings.value + if (settings != null) { + call.updateMediaManagerFromSettings(settings) + } else { + logger.w { + "[join] Call settings were null - this should never happen after a call" + + "is joined. MediaManager will not be initialised with server settings." + } + } + return result + } + if (result is Failure) { + session.value = null + 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 + } + } + delay((retryCount - 1) * 1000L) + } + 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)) + } + + suspend fun joinAndRing( + members: List, + createOptions: CreateCallOptions? = CreateCallOptions(members), + video: Boolean = call.isVideoEnabled(), + callJoinInterceptor: CallJoinInterceptor? = null, + ): Result { + logger.d { "[joinAndRing] #ringing; #track; members: $members, video: $video" } + state.toggleJoinAndRingProgress(true) + return join( + ring = false, + createOptions = createOptions, + callJoinInterceptor = callJoinInterceptor, + ).flatMap { rtcSession -> + logger.d { "[joinAndRing] Joined #ringing; #track; ring: $members" } + call.ring(RingCallRequest(call.isVideoEnabled(), members)).map { + logger.d { "[joinAndRing] Ringed #ringing; #track; ring: $members" } + clientImpl.state._ringingCall.value = call + rtcSession + }.onError { + logger.e { "[joinAndRing] Ring failed #ringing; #track; error: $it" } + state.toggleJoinAndRingProgress(false) + call.leave( + CallLeaveReason.Backend( + BackendCause.RING_FAILED, + message = "ring-failed (${it.message})", + ), + ) + } + } + } + + fun isPermanentError(error: Any): Boolean { + if (error is Error.ThrowableError) { + if (error.message.contains("Unable to resolve host")) { + return false + } + } + return true + } + + suspend fun joinInternal( + create: Boolean = false, + createOptions: CreateCallOptions? = null, + ring: Boolean = false, + notify: Boolean = false, + hintHighScaleLivestreamPublisher: Boolean? = null, + joinAnalyticsModel: JoinAnalyticsModel, + ): Result { + call.nonFastReconnectAttempts = 0 + call.cancelSfuObservers() + + if (session.value != null) { + return Failure(Error.GenericError("Call ${call.cid} has already been joined")) + } + logger.d { + "[joinInternal] #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" + } + + call.connectStartTime = System.currentTimeMillis() + + // step 1. call the join endpoint to get a list of SFUs + val locationResult = clientImpl.getCachedLocation() + if (locationResult !is Success) { + return locationResult as Failure + } + call.location = locationResult.value + + val options = createOptions + ?: if (create) { + CreateCallOptions() + } else { + null + } + val result = + joinRequest( + options, + locationResult.value, + ring = ring, + notify = notify, + hintHighScaleLivestreamPublisher = hintHighScaleLivestreamPublisher, + joinAnalyticsModel = joinAnalyticsModel, + ) + + if (result !is Success) { + return result as Failure + } + val sfuToken = result.value.credentials.token + val sfuUrl = result.value.credentials.server.url + val sfuWsUrl = result.value.credentials.server.wsEndpoint + val sfuName = result.value.credentials.server.edgeName + val iceServers = result.value.credentials.iceServers.map { it.toIceServer() } + val localSession = if (Call.testInstanceProvider.rtcSessionCreator != null) { + Call.testInstanceProvider.rtcSessionCreator!!.invoke() + } else { + RtcSession( + sessionId = call.sessionId, + apiKey = clientImpl.apiKey, + lifecycle = clientImpl.coordinatorConnectionModule.lifecycle, + client = call.client, + call = call, + sfuUrl = sfuUrl, + sfuWsUrl = sfuWsUrl, + sfuToken = sfuToken, + sfuName = sfuName, + remoteIceServers = iceServers, + powerManager = call.powerManager, + sfuAnalytics = callAnalytics.sfuAnalytics.apply { + sfuAnalyticsStateHolder.updateSfuId( + sfuName, + ) + }, + ) + } + session.value = localSession + + session.value?.let { + state._connection.value = RealtimeConnection.Joined(it) + } + + when (val result = session.value?.connectInternal()) { + is SfuConnectionResult.Connected -> Unit + is SfuConnectionResult.Failed -> + return Failure( + Error.GenericError(result.error.message ?: "RtcSession error occurred."), + ) + null -> + return Failure(Error.GenericError("RtcSession was null during connect")) + } + call.client.state.setActiveCall(call) + call.monitorSession(result.value) + return Success(value = session.value!!) + } + + suspend fun joinRequest( + create: CreateCallOptions? = null, + location: String, + migratingFrom: String? = null, + migratingFromList: List? = null, + ring: Boolean = false, + notify: Boolean = false, + hintHighScaleLivestreamPublisher: Boolean? = null, + joinAnalyticsModel: JoinAnalyticsModel, + ): Result { + val migratingFromList = + migratingFromList ?: call.getFailedSfuIdsSnapshot().takeIf { it.isNotEmpty() } + callAnalytics.joinAnalytics.onJoinRequestStart(joinAnalyticsModel.joinReason) + val result = clientImpl.joinCall( + type, id, + create = create != null, + members = create?.memberRequestsFromIds(), + custom = create?.custom, + settingsOverride = create?.settings, + startsAt = create?.startsAt, + team = create?.team, + ring = ring, + notify = notify, + location = location, + migratingFrom = migratingFrom, + migratingFromList = migratingFromList, + 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/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt new file mode 100644 index 00000000000..6d4465befaf --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -0,0 +1,146 @@ +/* + * 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.call.components + +import io.getstream.log.taggedLogger +import io.getstream.result.Result +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.SdkCause +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController +import io.getstream.video.android.core.utils.AtomicUnitCall +import io.getstream.video.android.core.utils.safeCall +import kotlinx.coroutines.launch + +/** + * Owns the call's lifecycle / teardown for a [Call]: leaving, ending, the single-shot + * leave guard ([AtomicUnitCall]), the destroyed flag, and the ordered cleanup of state, + * session, jobs and media. + */ +internal class CallLifecycleManager( + private val call: Call, +) { + private val logger by taggedLogger("Call:LifecycleManager:${call.type}:${call.id}") + + private val clientImpl get() = call.clientImpl + private val state get() = call.state + private val session get() = call.session + private val callAnalytics get() = call.callAnalytics + + // Atomic controls + private var atomicLeave = AtomicUnitCall() + + /** Call has been left and the object is cleaned up and destroyed. */ + var isDestroyed = false + + /** + * Time (in millis) when the full reconnection flow started. Will be null again once + * the reconnection flow ends (success or failure) + */ + private var sfuSocketReconnectionTime: Long? = null + + /** Resets the leave guard so a fresh join can run after a previous leave. */ + fun resetLeaveGuard() { + atomicLeave = AtomicUnitCall() + } + + fun leave(reason: CallLeaveReason) { + logger.d { "[leave] #ringing; call_cid:${call.cid}" } + internalLeave(reason) + } + + fun leave(reason: String = "user") { + logger.d { "[leave] #ringing; no args, call_cid:${call.cid}" } + internalLeave(CallLeaveReason.Custom(reason)) + } + + private fun internalLeave(reason: CallLeaveReason) = atomicLeave { + call.stopConnectionMonitors() + callAnalytics.stopObservers() + call.cancelSfuObservers() + state._connection.value = RealtimeConnection.Disconnected + logger.v { "[leave] #ringing; call_id = ${call.id}" } + if (isDestroyed) { + logger.w { "[leave] #ringing; Call already destroyed, ignoring" } + return@atomicLeave + } + isDestroyed = true + + sfuSocketReconnectionTime = null + + /** + * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) + */ + call.stopScreenSharing() + call.camera.disable() + call.microphone.disable() + + if (call.id == call.client.state.activeCall.value?.id) { + call.client.state.removeActiveCall(call) // Will also stop CallService + } + + if (call.id == call.client.state.ringingCall.value?.id) { + call.client.state.removeRingingCall(call) + } + + TelecomCallController(call.client.context) + .leaveCall(call) + + (call.client as StreamVideoClient).onCallCleanUp(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 = call.collectStats() + session.value?.sendCallStats(stats) + } + // Must complete before cleanup() cancels the session's supervisor job. + safeCall { session.value?.sendLeaveEvent(leaveReason) } + cleanup() + } + } + + /** ends the call for yourself as well as other users */ + suspend fun end(): Result { + // end the call for everyone + val result = clientImpl.endCall(call.type, call.id) + // cleanup + leave( + CallLeaveReason.SdkDriven( + cause = SdkCause.END_CALL, + message = "CALL_ENDED", // Call ended by local user + ), + ) + return result + } + + fun cleanup() { + // monitor.stop() + state.cleanup() + session.value?.cleanup() + call.shutDownJobsGracefully() + call.stopStatsReporting() + call.mediaManager.cleanup() // TODO Rahul, Verify Later: need to check which call has owned the media at the moment(probably use active call) + session.value = null + // Cleanup the call's scope provider + call.scopeProvider.cleanup() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt new file mode 100644 index 00000000000..4173084d286 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -0,0 +1,273 @@ +/* + * 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.call.components + +import android.content.Intent +import io.getstream.android.video.generated.models.AudioSettingsResponse +import io.getstream.android.video.generated.models.CallSettingsResponse +import io.getstream.android.video.generated.models.OwnCapability +import io.getstream.android.video.generated.models.VideoSettingsResponse +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CameraDirection +import io.getstream.video.android.core.DeviceStatus +import io.getstream.video.android.core.MediaManagerImpl +import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory +import io.getstream.video.android.core.call.utils.SoundInputProcessor +import io.getstream.video.android.core.utils.RampValueUpAndDownHelper +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.webrtc.audio.JavaAudioDeviceModule.AudioSamples + +/** + * Owns the media pipeline for a [Call]: the [StreamPeerConnectionFactory] lifecycle, the + * [MediaManagerImpl] (camera / microphone / speaker / screen share), audio-level monitoring, + * settings-driven device initialisation and screen sharing. + */ +internal class CallMediaManager( + private val call: Call, +) { + private val logger by taggedLogger("Call:MediaManager:${call.type}:${call.id}") + + private val clientImpl get() = call.clientImpl + + private val soundInputProcessor = SoundInputProcessor(thresholdCrossedCallback = { + if (!mediaManager.microphone.isEnabled.value) { + call.state.markSpeakingAsMuted() + } + }) + private val audioLevelOutputHelper = RampValueUpAndDownHelper() + + /** Smoothed local microphone volume level (0..1). */ + val localMicrophoneAudioLevel: StateFlow = audioLevelOutputHelper.currentLevel + + // peerConnectionFactory is nullable and recreated when audioBitrateProfile changes (before joining) + private var _peerConnectionFactory: StreamPeerConnectionFactory? = null + + var peerConnectionFactory: StreamPeerConnectionFactory + get() { + if (_peerConnectionFactory == null) { + _peerConnectionFactory = StreamPeerConnectionFactory( + context = clientImpl.context, + audioProcessing = clientImpl.audioProcessing, + audioUsage = clientImpl.callServiceConfigRegistry.get(call.type).audioUsage, + audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(call.type).audioUsage }, + audioBitrateProfileProvider = { mediaManager.microphone.audioBitrateProfile.value }, + sharedEglBaseProvider = { call.eglBase }, + webRtcLoggingLevel = clientImpl.loggingLevel.webRtcLoggingLevel, + ) + } + return _peerConnectionFactory!! + } + set(value) { + _peerConnectionFactory = value + } + + val mediaManager by lazy { + if (Call.testInstanceProvider.mediaManagerCreator != null) { + Call.testInstanceProvider.mediaManagerCreator!!.invoke() + } else { + MediaManagerImpl( + clientImpl.context, + call, + call.scope, + call.eglBase.eglBaseContext, + clientImpl.callServiceConfigRegistry.get(call.type).audioUsage, + ) { clientImpl.callServiceConfigRegistry.get(call.type).audioUsage } + } + } + + /** Starts streaming smoothed microphone audio levels into [localMicrophoneAudioLevel]. */ + fun startAudioLevelMonitoring() { + call.scope.launch { + soundInputProcessor.currentAudioLevel.collect { + audioLevelOutputHelper.rampToValue(it) + } + } + } + + fun processAudioSample(audioSample: AudioSamples) { + soundInputProcessor.processSoundInput(audioSample.data) + } + + /** + * Checks if the audioBitrateProfile has changed since the factory was created, + * and recreates the factory if needed. This should only be called before joining. + */ + fun ensureFactoryMatchesAudioProfile() { + val factory = _peerConnectionFactory + + // If factory hasn't been created yet, it will be created with current profile automatically + if (factory == null) { + return + } + + // Check if current profile differs from the profile used to create the factory + val factoryProfile = factory.audioBitrateProfile + val currentProfile = mediaManager.microphone.audioBitrateProfile.value + + if (factoryProfile != null && currentProfile != factoryProfile) { + logger.i { + "Audio bitrate profile changed from $factoryProfile to $currentProfile. " + + "Recreating factory before joining." + } + recreateFactoryAndAudioTracks() + } + } + + /** + * Recreates peerConnectionFactory, audioSource, audioTrack, videoSource and videoTrack + * with the current audioBitrateProfile. This should only be called before the call is joined. + */ + fun recreateFactoryAndAudioTracks() { + val wasMicrophoneEnabled = mediaManager.microphone.status.value is DeviceStatus.Enabled + val wasCameraEnabled = mediaManager.camera.status.value is DeviceStatus.Enabled + + // Dispose all tracks and sources first + mediaManager.disposeTracksAndSources() + + // Recreate the factory (which will use the new audioBitrateProfile) + recreatePeerConnectionFactory() + + // Re-enable tracks if they were enabled + if (wasMicrophoneEnabled) { + // audioTrack will be recreated on next access, then we enable it + mediaManager.microphone.enable(fromUser = false) + } + if (wasCameraEnabled) { + // videoTrack will be recreated on next access, then we enable it + mediaManager.camera.enable(fromUser = false) + } + } + + /** + * Recreates peerConnectionFactory with the current audioBitrateProfile. + * This should only be called before the call is joined. + */ + fun recreatePeerConnectionFactory() { + _peerConnectionFactory?.dispose() + _peerConnectionFactory = null + // Next access to peerConnectionFactory will recreate it with current profile + } + + fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) { + val camera = mediaManager.camera + val microphone = mediaManager.microphone + val speaker = mediaManager.speaker + + // Speaker + if (speaker.status.value is DeviceStatus.NotSelected) { + val enableSpeaker = + if (callSettings.video.cameraDefaultOn || camera.status.value is DeviceStatus.Enabled) { + // if camera is enabled then enable speaker. Eventually this should + // be a new audio.defaultDevice setting returned from backend + true + } else { + callSettings.audio.defaultDevice == AudioSettingsResponse.DefaultDevice.Speaker || + callSettings.audio.speakerDefaultOn + } + + speaker.setEnabled(enabled = enableSpeaker) + } + + monitorHeadset() + + // Camera + if (camera.status.value is DeviceStatus.NotSelected) { + val defaultDirection = + if (callSettings.video.cameraFacing == VideoSettingsResponse.CameraFacing.Front) { + CameraDirection.Front + } else { + CameraDirection.Back + } + camera.setDirection(defaultDirection) + camera.setEnabled(callSettings.video.cameraDefaultOn) + } + + // Mic + if (microphone.status.value == DeviceStatus.NotSelected) { + val enabled = callSettings.audio.micDefaultOn + microphone.setEnabled(enabled) + } + } + + private fun monitorHeadset() { + val microphone = mediaManager.microphone + microphone.devices.onEach { availableDevices -> + logger.d { + "[monitorHeadset] new available devices, prev selected: ${microphone.nonHeadsetFallbackDevice}" + } + + val bluetoothHeadset = + availableDevices.find { it is StreamAudioDevice.BluetoothHeadset } + val wiredHeadset = availableDevices.find { it is StreamAudioDevice.WiredHeadset } + + if (bluetoothHeadset != null) { + logger.d { "[monitorHeadset] BT headset selected" } + microphone.select(bluetoothHeadset) + } else if (wiredHeadset != null) { + logger.d { "[monitorHeadset] wired headset found" } + microphone.select(wiredHeadset) + } else { + logger.d { "[monitorHeadset] no headset found" } + + microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> + logger.d { "[monitorHeadset] before device selected" } + microphone.select(deviceBeforeHeadset) + } + } + }.launchIn(call.scope) + } + + fun startScreenSharing( + mediaProjectionPermissionResultData: Intent, + includeAudio: Boolean = false, + ) { + if (call.state.ownCapabilities.value.contains(OwnCapability.Screenshare)) { + call.session.value?.setScreenShareTrack() + mediaManager.screenShare.enable( + mediaProjectionPermissionResultData, + includeAudio = includeAudio, + ) + } else { + logger.w { "Can't start screen sharing - user doesn't have wnCapability.Screenshare permission" } + } + } + + fun stopScreenSharing() { + mediaManager.screenShare.disable(fromUser = true) + } + + fun isAudioProcessingEnabled(): Boolean { + return peerConnectionFactory.isAudioProcessingEnabled() + } + + fun setAudioProcessingEnabled(enabled: Boolean) { + return peerConnectionFactory.setAudioProcessingEnabled(enabled) + } + + fun toggleAudioProcessing(): Boolean { + return peerConnectionFactory.toggleAudioProcessing() + } + + fun cleanup() { + mediaManager.cleanup() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt new file mode 100644 index 00000000000..9d10f214edb --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt @@ -0,0 +1,543 @@ +/* + * 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.call.components + +import io.getstream.log.taggedLogger +import io.getstream.result.Result.Success +import io.getstream.video.android.core.BackendCause +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.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.call.FastReconnectResult +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.SfuConnectionResult +import io.getstream.video.android.core.model.toIceServer +import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import stream.video.sfu.event.ReconnectDetails +import stream.video.sfu.models.WebsocketReconnectStrategy +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Outcome of a single reconnect attempt. Each reconnect method returns one of + * these instead of throwing, making the control flow in the reconnect loop + * explicit and exhaustively checked by the compiler. + */ +private sealed class ReconnectOutcome { + /** Reconnect succeeded — exit the loop. */ + object Success : ReconnectOutcome() + + /** A required precondition is missing (no session, no location). Terminal — don't retry. */ + data class PreconditionNotMet(val reason: String) : ReconnectOutcome() + + /** Peer connections are stale and can't be reused. Should escalate to REJOIN. */ + object PeerConnectionStale : ReconnectOutcome() + + /** Server-initiated disconnect — leave the call cleanly. */ + object Disconnect : ReconnectOutcome() + + /** A transient failure occurred. The loop should retry with escalation. */ + data class Failed(val error: Exception) : ReconnectOutcome() +} + +/** + * Owns the unified reconnection state machine for a [Call]: the FAST / REJOIN / MIGRATE + * strategies, escalation logic, the single-flight reconnect mutex and the set of failed + * SFU edge names used to populate `migrating_from_list`. + */ +internal class CallReconnector( + private val call: Call, +) { + private val logger by taggedLogger("Call:Reconnector:${call.type}:${call.id}") + + private val clientImpl get() = call.clientImpl + private val state get() = call.state + private val session get() = call.session + private val callAnalytics get() = call.callAnalytics + + private val reconnectMutex = Mutex() + + /** + * SFU IDs (edge names) we failed to connect to (e.g. SFU_FULL). Sent in migrating_from_list + * when requesting new credentials so the coordinator can exclude them. + */ + private val failedSfuIds: MutableSet = ConcurrentHashMap.newKeySet() + + /** + * Unified reconnection entry point. + * + * All callers (stateJob, NetworkStateListener, error handlers) funnel through + * this method. It acquires [reconnectMutex] so only one flow runs at a time, + * and implements a retry loop that **escalates** the strategy on failure: + * + * - **FAST** → **REJOIN** after [MAX_FAST_RECONNECT_ATTEMPTS] failures *or* + * when the elapsed time exceeds the reconnect deadline. + * - **MIGRATE** → **REJOIN** if the migration attempt fails. + * - **DISCONNECT** → leaves the call (server-initiated). + * - **UNSPECIFIED** → treated as FAST (HealthMonitor already attempted the WS). + * + * The loop exits when the connection state becomes [RealtimeConnection.Connected], + * [RealtimeConnection.ReconnectingFailed], or [RealtimeConnection.Disconnected]. + * + * @param strategy the initial reconnection strategy requested by the caller. + * @param reason a human-readable reason for logging / tracing. + */ + suspend fun reconnect( + strategy: WebsocketReconnectStrategy, + reason: String, + ) { + val conn = state.connection.value + logger.d { "[reconnect] Entry — strategy=$strategy reason=$reason connection=$conn" } + + if (call.isDestroyed || conn is RealtimeConnection.Disconnected) { + logger.d { + "[reconnect] Call already left/destroyed (isDestroyed=${call.isDestroyed}, conn=$conn) — skipping ($reason)" + } + return + } + + // Use tryLock so concurrent triggers (stateJob, NetworkStateListener, + // SfuSocket errors) don't queue up. If a reconnect loop is already + // running it will handle recovery; redundant callers return immediately. + if (!reconnectMutex.tryLock()) { + logger.d { "[reconnect] Active reconnect loop running — skipping ($reason)" } + return + } + var currentStrategy = strategy + try { + // Re-check after acquiring the lock — bail only if the user left. + // We deliberately allow reconnect from Connected (SFU/network may + // request it) and from ReconnectingFailed (fresh trigger like + // network recovery should be retried). + val currentConn = state.connection.value + if (currentConn is RealtimeConnection.Disconnected) { + logger.d { "[reconnect] State is $currentConn — no reconnect needed ($reason)" } + return + } + + val isMigrate = strategy == + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE + state._connection.value = if (isMigrate) { + RealtimeConnection.Migrating + } else { + RealtimeConnection.Reconnecting + } + + val loopStartTime = System.currentTimeMillis() + // Local iteration counter for this reconnect() invocation only. + // Controls MAX_RECONNECT_ATTEMPTS cap and FAST→REJOIN escalation. + // Distinct from the class-level reconnectAttempts which is cumulative. + var loopIteration = 0 + + while (true) { + // EARLY EXIT CASE 1 - State based + val connectionState = state.connection.value + if (connectionState is RealtimeConnection.Connected || + connectionState is RealtimeConnection.ReconnectingFailed || + connectionState is RealtimeConnection.Disconnected + ) { + logger.i { "[reconnect] Loop finished — state=$connectionState" } + break + } + + // EARLY EXIT CASE 2 - count based + if (loopIteration >= MAX_RECONNECT_ATTEMPTS) { + logger.w { "[reconnect] Max reconnect attempts ($MAX_RECONNECT_ATTEMPTS) reached — giving up" } + state._connection.value = RealtimeConnection.ReconnectingFailed + break + } + + // EARLY EXIT CASE 3 - time based + val elapsedMs = System.currentTimeMillis() - loopStartTime + if (clientImpl.leaveAfterDisconnectSeconds > 0 && + elapsedMs / 1000 > clientImpl.leaveAfterDisconnectSeconds + ) { + logger.w { "[reconnect] Disconnection timeout reached — giving up" } + state._connection.value = RealtimeConnection.ReconnectingFailed + break + } + + // Wait for network before doing anything else. Polls without + // consuming the attempt budget — the elapsed-time guard below + // will still fire if we wait too long. + if (!call.isNetworkConnected()) { + logger.d { + "[reconnect] Network unavailable — waiting for connectivity (loopIteration=$loopIteration)" + } + delay(RECONNECT_DELAY_MS) + continue + } + + val currentTimeInMillis = System.currentTimeMillis() + if (currentTimeInMillis - loopStartTime >= call.reconnectDeadlineMillis) { + currentStrategy = when (currentStrategy) { + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_UNSPECIFIED, + -> { + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN + } + + else -> currentStrategy + } + } + + logger.i { + "[reconnect] loopIteration=$loopIteration strategy=$currentStrategy reason=$reason" + } + + val outcome = when (currentStrategy) { + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_UNSPECIFIED, + -> reconnectFast(reason) + + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN -> { + call.nonFastReconnectAttempts++ + reconnectRejoin( + reason, + JoinAnalyticsModel(call.nonFastReconnectAttempts, JoinReason.ReJoin), + ) + } + + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE -> { + call.nonFastReconnectAttempts++ + reconnectMigrate( + JoinAnalyticsModel(call.nonFastReconnectAttempts, JoinReason.Migrate), + ) + } + + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_DISCONNECT -> + ReconnectOutcome.Disconnect + } + + when (outcome) { + is ReconnectOutcome.Success -> break + + is ReconnectOutcome.Disconnect -> { + logger.w { "[reconnect] DISCONNECT requested — leaving call" } + call.leave( + CallLeaveReason.Backend(BackendCause.SFU_DISCONNECT), + ) + break + } + + is ReconnectOutcome.PreconditionNotMet -> { + logger.w { "[reconnect] Precondition not met — giving up: ${outcome.reason}" } + state._connection.value = RealtimeConnection.ReconnectingFailed + break + } + + is ReconnectOutcome.PeerConnectionStale -> { + logger.w { "[reconnect] Peer connections stale — escalating to REJOIN" } + delay(RECONNECT_DELAY_MS) + loopIteration++ + currentStrategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN + } + + is ReconnectOutcome.Failed -> { + logger.w { + "[reconnect] $currentStrategy (${call.nonFastReconnectAttempts}) failed: ${outcome.error.message}" + } + + delay(RECONNECT_DELAY_MS) + loopIteration++ + + val wasMigrating = currentStrategy == + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE + val pastFastReconnectDeadline = (System.currentTimeMillis() - loopStartTime) > + call.reconnectDeadlineMillis + val shouldEscalateToRejoin = wasMigrating || + pastFastReconnectDeadline + + if (shouldEscalateToRejoin) { + currentStrategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN + } + logger.i { "[reconnect] Next strategy: $currentStrategy (loopIteration=$loopIteration)" } + } + } + } + + if (state.connection.value is RealtimeConnection.ReconnectingFailed) { + val message = "[reconnect] All recovery attempts exhausted — leaving call ($reason)" + logger.w { message } + callAnalytics.joinAnalytics.onJoinRequestRetryExhausted( + loopIteration, + AnalyticsCallAbortReason.RETRY_EXHAUSTED.name, + message, + ) + call.leave( + CallLeaveReason.RetryExhausted( + loopIteration, + "reconnect-failed", + message, + ), + ) + } + } finally { + // Always release the mutex — even on exceptions or coroutine + // cancellation — so future reconnect() calls aren't permanently blocked. + reconnectMutex.unlock() + logger.d { + "[reconnect] Free reconnectMutex, initialStrategy: $strategy, finalStrategy: $currentStrategy" + } + } + } + + /** + * Fast reconnect to the same SFU with the same participant session. + * Reuses the existing session ID — no previous_session_id needed since the + * SFU already knows this participant. + */ + private suspend fun reconnectFast(reason: String): ReconnectOutcome { + logger.d { "[reconnectFast] reconnectAttempts=${call.nonFastReconnectAttempts}" } + val currentSession = session.value + ?: return ReconnectOutcome.PreconditionNotMet("No active session for fast reconnect") + + val stats = call.collectStats() + currentSession.sendCallStats(stats) + + currentSession.prepareReconnect() + state._connection.value = RealtimeConnection.Reconnecting + call.reconnectStartTime = System.currentTimeMillis() + + val (_, subscriptionsInfo, publishingInfo) = currentSession.currentSfuInfo() + val reconnectDetails = ReconnectDetails( + previous_session_id = "", + strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + announced_tracks = publishingInfo, + subscriptions = subscriptionsInfo, + reconnect_attempt = call.nonFastReconnectAttempts, + reason = reason, + ) + return when (val result = currentSession.fastReconnect(reconnectDetails)) { + is FastReconnectResult.Connected -> ReconnectOutcome.Success + is FastReconnectResult.PeerConnectionStale -> ReconnectOutcome.PeerConnectionStale + is FastReconnectResult.Failed -> ReconnectOutcome.Failed(result.error) + } + } + + /** + * Rejoin a call. Creates a new session ID and joins as a new participant. + * 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, + joinAnalyticsModel: JoinAnalyticsModel, + ): ReconnectOutcome { + logger.d { "[reconnectRejoin] reconnectAttempts=${call.nonFastReconnectAttempts}" } + state._connection.value = RealtimeConnection.Reconnecting + val loc = call.location + ?: return ReconnectOutcome.PreconditionNotMet("No location available for rejoin") + val oldSession = session.value + ?: return ReconnectOutcome.PreconditionNotMet("No active session for rejoin") + call.reconnectStartTime = System.currentTimeMillis() + + val joinResponse = call.joinRequest(location = loc, joinAnalyticsModel = joinAnalyticsModel) + if (joinResponse !is Success) { + return ReconnectOutcome.Failed( + Exception("Failed to get join response: ${joinResponse.errorOrNull()}"), + ) + } + + val cred = joinResponse.value.credentials + val currentOptions = oldSession.publisher.value?.currentOptions() + logger.i { "Rejoin SFU ${oldSession.sfuUrl} to ${cred.server.url}" } + + call.sessionId = UUID.randomUUID().toString() + val (prevSessionId, subscriptionsInfo, publishingInfo) = oldSession.currentSfuInfo() + val reconnectDetails = ReconnectDetails( + previous_session_id = prevSessionId, + strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + announced_tracks = publishingInfo, + subscriptions = subscriptionsInfo, + reconnect_attempt = call.nonFastReconnectAttempts, + reason = reason, + ) + call.state.removeParticipant(prevSessionId) + oldSession.prepareRejoin("rejoin") + val newSession = RtcSession( + clientImpl, + call.nonFastReconnectAttempts, + call.powerManager, + call, + call.sessionId, + clientImpl.apiKey, + clientImpl.coordinatorConnectionModule.lifecycle, + cred.server.url, + cred.server.wsEndpoint, + cred.token, + cred.server.edgeName, + cred.iceServers.map { ice -> ice.toIceServer() }, + sfuAnalytics = callAnalytics.sfuAnalytics.apply { + sfuAnalyticsStateHolder.updateSfuId( + cred.server.edgeName, + ) + }, + ) + session.value = newSession + + return when ( + val result = newSession.connectInternal( + reconnectDetails, + currentOptions, + JoinAnalyticsModel(joinAnalyticsModel.retryAttempt), + ) + ) { + is SfuConnectionResult.Connected -> { + newSession.sfuTracer.trace("rejoin", reason) + call.monitorSession(joinResponse.value) + ReconnectOutcome.Success + } + is SfuConnectionResult.Failed -> ReconnectOutcome.Failed(result.error) + } + } + + /** + * 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(joinAnalyticsModel: JoinAnalyticsModel): ReconnectOutcome { + logger.d { "[reconnectMigrate] Migrating" } + state._connection.value = RealtimeConnection.Migrating + val loc = call.location + ?: return ReconnectOutcome.PreconditionNotMet("No location available for migrate") + val oldSession = session.value + ?: return ReconnectOutcome.PreconditionNotMet("No active session for migrate") + call.reconnectStartTime = System.currentTimeMillis() + addFailedSfuId(oldSession.sfuName) + + val joinResponse = + call.joinRequest( + location = loc, + migratingFrom = oldSession.sfuName, + joinAnalyticsModel = joinAnalyticsModel, + ) + if (joinResponse !is Success) { + return ReconnectOutcome.Failed( + Exception( + "Failed to get join response during migration: ${joinResponse.errorOrNull()}", + ), + ) + } + + val cred = joinResponse.value.credentials + val currentOptions = oldSession.publisher.value?.currentOptions() + val oldSfuName = oldSession.sfuName + logger.i { "[reconnectMigrate] Migrate SFU $oldSfuName to ${cred.server.edgeName}" } + + val (_, subscriptionsInfo, publishingInfo) = oldSession.currentSfuInfo() + val reconnectDetails = ReconnectDetails( + previous_session_id = "", + strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE, + announced_tracks = publishingInfo, + subscriptions = subscriptionsInfo, + from_sfu_id = oldSfuName, + reconnect_attempt = call.nonFastReconnectAttempts, + ) + + val stats = call.collectStats() + oldSession.sendCallStats(stats) + oldSession.enterMigration() + + val newSession = RtcSession( + clientImpl, + call.nonFastReconnectAttempts, + call.powerManager, + call, + call.sessionId, + clientImpl.apiKey, + clientImpl.coordinatorConnectionModule.lifecycle, + cred.server.url, + cred.server.wsEndpoint, + cred.token, + cred.server.edgeName, + cred.iceServers.map { ice -> ice.toIceServer() }, + sfuAnalytics = callAnalytics.sfuAnalytics.apply { + sfuAnalyticsStateHolder.updateSfuId( + cred.server.edgeName, + ) + }, + ) + session.value = newSession + + return try { + val result = newSession.connectInternal( + reconnectDetails, + currentOptions, + JoinAnalyticsModel(joinAnalyticsModel.retryAttempt), + ) + when (result) { + is SfuConnectionResult.Connected -> { + call.monitorSession(joinResponse.value) + ReconnectOutcome.Success + } + is SfuConnectionResult.Failed -> ReconnectOutcome.Failed(result.error) + } + } finally { + oldSession.finalizeMigration() + } + } + + suspend fun fastReconnect(reason: String = "unknown") { + reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, reason) + } + + suspend fun rejoin(reason: String = "unknown") { + reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, reason) + } + + suspend fun migrate() { + reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE, "migrate") + } + + /** Adds the given SFU ID (edge name) to the failed set (for migrating_from_list). */ + private fun addFailedSfuId(sfuId: String) { + if (sfuId.isBlank()) return + failedSfuIds.add(sfuId) + } + + /** Returns a snapshot of failed SFU IDs to send as migrating_from_list. */ + fun getFailedSfuIdsSnapshot(): List = failedSfuIds.toList() + + /** Clears the failed SFU list (e.g. after a successful join). */ + fun clearFailedSfuIds() { + failedSfuIds.clear() + } + + companion object { + /** How many consecutive FAST reconnect failures are allowed before + * escalating to a full REJOIN. Kept small because each failed FAST + * attempt can cost up to DEFAULT_SOCKET_TIMEOUT (10 s) waiting for + * the WebSocket handshake to time out. */ + private const val MAX_FAST_RECONNECT_ATTEMPTS = 3 + + /** Absolute upper bound on loop iterations across all strategies + * (FAST + REJOIN + MIGRATE combined). Prevents infinite retries + * when every strategy keeps failing. */ + private const val MAX_RECONNECT_ATTEMPTS = 10 + + /** Delay between consecutive reconnect attempts (both after a + * failed attempt and while polling for network availability + * during FAST reconnect). Kept short so the SDK reacts quickly + * once conditions improve. */ + private const val RECONNECT_DELAY_MS = 500L + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt new file mode 100644 index 00000000000..63886d59e9d --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt @@ -0,0 +1,209 @@ +/* + * 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.call.components + +import android.graphics.Bitmap +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.call.connection.Subscriber +import io.getstream.video.android.core.call.video.YuvFrame +import io.getstream.video.android.core.model.AudioTrack +import io.getstream.video.android.core.model.PreferredVideoResolution +import io.getstream.video.android.core.model.VideoTrack +import io.getstream.webrtc.android.ui.VideoTextureViewRenderer +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.webrtc.RendererCommon +import org.webrtc.VideoSink +import stream.video.sfu.models.TrackType +import stream.video.sfu.models.VideoDimension +import kotlin.coroutines.resume + +/** + * Handles binding video tracks to renderers, visibility / track-dimension updates, + * screenshots and incoming media-quality overrides for a [Call]. + */ +internal class CallRenderer( + private val call: Call, +) { + private val logger by taggedLogger("Call:Renderer:${call.type}:${call.id}") + + private val session get() = call.session + + fun setVisibility( + sessionId: String, + trackType: TrackType, + visible: Boolean, + viewportId: String = sessionId, + ) { + logger.i { + "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" + } + session.value?.updateTrackDimensions( + sessionId, + trackType, + visible, + Subscriber.defaultVideoDimension, + viewportId, + ) + } + + fun setVisibility( + sessionId: String, + trackType: TrackType, + visible: Boolean, + viewportId: String = sessionId, + width: Int, + height: Int, + ) { + logger.i { + "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" + } + session.value?.updateTrackDimensions( + sessionId, + trackType, + visible, + VideoDimension(width, height), + viewportId, + ) + } + + fun initRenderer( + videoRenderer: VideoTextureViewRenderer, + sessionId: String, + trackType: TrackType, + onRendered: (VideoTextureViewRenderer) -> Unit = {}, + viewportId: String = sessionId, + ) { + logger.d { "[initRenderer] #sfu; #track; sessionId: $sessionId" } + + // Note this comes from the shared eglBase + videoRenderer.init( + call.eglBase.eglBaseContext, + object : RendererCommon.RendererEvents { + override fun onFirstFrameRendered() { + val width = videoRenderer.measuredWidth + val height = videoRenderer.measuredHeight + logger.i { + "[initRenderer.onFirstFrameRendered] #sfu; #track; " + + "trackType: $trackType, dimension: ($width - $height), " + + "sessionId: $sessionId" + } + if (trackType != TrackType.TRACK_TYPE_SCREEN_SHARE) { + session.value?.updateTrackDimensions( + sessionId, + trackType, + true, + VideoDimension(width, height), + viewportId, + ) + } + onRendered(videoRenderer) + call.callAnalytics.videoAnalytics.firstVideoFrameRendered( + + trackType, + width, + height, + rtcSession = session.value, + sessionId, + call.sessionId, + ) + } + + override fun onFrameResolutionChanged( + videoWidth: Int, + videoHeight: Int, + rotation: Int, + ) { + val width = videoRenderer.measuredWidth + val height = videoRenderer.measuredHeight + logger.v { + "[initRenderer.onFrameResolutionChanged] #sfu; #track; " + + "trackType: $trackType, " + + "viewport size: ($width - $height), " + + "video size: ($videoWidth - $videoHeight), " + + "sessionId: $sessionId" + } + + if (trackType != TrackType.TRACK_TYPE_SCREEN_SHARE) { + session.value?.updateTrackDimensions( + sessionId, + trackType, + true, + VideoDimension(width, height), + viewportId, + ) + } + } + }, + ) + } + + suspend fun takeScreenshot(track: VideoTrack): Bitmap? { + return suspendCancellableCoroutine { continuation -> + var screenshotSink: VideoSink? = null + screenshotSink = VideoSink { + // make sure we stop after first frame is delivered + if (!continuation.isActive) { + return@VideoSink + } + it.retain() + val bitmap = YuvFrame.bitmapFromVideoFrame(it) + it.release() + + // This has to be launched asynchronously - removing the sink on the + // same thread as the videoframe is delivered will lead to a deadlock + // (needs investigation why) + call.scope.launch { + track.video.removeSink(screenshotSink) + } + continuation.resume(bitmap) + } + + track.video.addSink(screenshotSink) + } + } + + fun setPreferredIncomingVideoResolution( + resolution: PreferredVideoResolution?, + sessionIds: List? = null, + ) { + session.value?.let { session -> + session.trackOverridesHandler.updateOverrides( + sessionIds = sessionIds, + dimensions = resolution?.let { VideoDimension(it.width, it.height) }, + ) + } + } + + fun setIncomingVideoEnabled(enabled: Boolean?, sessionIds: List? = null) { + session.value?.trackOverridesHandler?.updateOverrides(sessionIds, visible = enabled) + } + + fun setIncomingAudioEnabled(enabled: Boolean, sessionIds: List? = null) { + val participantTrackMap = session.value?.subscriber?.value?.tracks ?: return + + val targetTracks = when { + sessionIds != null -> sessionIds.mapNotNull { participantTrackMap[it] } + else -> participantTrackMap.values.toList() + } + + targetTracks + .mapNotNull { it[TrackType.TRACK_TYPE_AUDIO] as? AudioTrack } + .forEach { it.enableAudio(enabled) } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt new file mode 100644 index 00000000000..1d043d9dce5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.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.call.components + +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.call.RtcSession +import kotlinx.coroutines.flow.MutableStateFlow +import java.util.UUID + +/** + * Owns the live RTC session state for a [Call] and the bookkeeping shared across the + * join / reconnect flows: the current [session], the participant [sessionId], the cached + * SFU [location], reconnect attempt counters and connect/reconnect timestamps. + * + * Keeping this state in a single component gives the join, reconnect, connectivity and + * lifecycle collaborators a single source of truth to depend on. + */ +internal class CallSessionManager( + @Suppress("unused") private val call: Call, +) { + /** Session handles all real time communication for video and audio. */ + val session: MutableStateFlow = MutableStateFlow(null) + + var sessionId = UUID.randomUUID().toString() + + val unifiedSessionId = UUID.randomUUID().toString() + + /** Cached SFU location used when (re)joining a call. */ + var location: String? = null + + /** Increment this only for REJOIN and MIGRATION strategies. */ + var nonFastReconnectAttempts = 0 + + var connectStartTime = 0L + var reconnectStartTime = 0L +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt new file mode 100644 index 00000000000..d4e4c921fdd --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt @@ -0,0 +1,91 @@ +/* + * 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.call.components + +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallStatsReport +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Periodically collects WebRTC stats for a [Call], reports them to the SFU, and exposes + * the latest report and latency history as observable flows. + */ +internal class CallStatsReporter( + private val call: Call, +) { + private val logger by taggedLogger("Call:StatsReporter:${call.type}:${call.id}") + + /** Contains stats events for observation. */ + val statsReport: MutableStateFlow = MutableStateFlow(null) + + /** Contains stats history. */ + val statLatencyHistory: MutableStateFlow> = MutableStateFlow(listOf(0, 0, 0)) + + private var callStatsReportingJob: Job? = null + + fun start(reportingIntervalMs: Long = 10_000) { + callStatsReportingJob?.cancel() + callStatsReportingJob = call.scope.launch { + // Wait a bit before we start capturing stats + delay(reportingIntervalMs) + + while (isActive) { + delay(reportingIntervalMs) + call.session.value?.sendCallStats( + report = collectStats(), + ) + } + } + } + + fun stop() { + callStatsReportingJob?.cancel() + } + + suspend fun collectStats(): CallStatsReport { + val session = call.session.value + val state = call.state + val publisherStats = runCatching { session?.getPublisherStats() }.getOrNull() + val subscriberStats = runCatching { session?.getSubscriberStats() }.getOrNull() + runCatching { + state.stats.updateFromRTCStats(publisherStats, isPublisher = true) + state.stats.updateFromRTCStats(subscriberStats, isPublisher = false) + state.stats.updateLocalStats() + }.onFailure { logger.w { "[collectStats] Failed to update stats: ${it.message}" } } + val local = state.stats._local.value + + val report = CallStatsReport( + publisher = publisherStats, + subscriber = subscriberStats, + local = local, + stateStats = state.stats, + ) + + statsReport.value = report + statLatencyHistory.value += report.stateStats.publisher.latency.value + if (statLatencyHistory.value.size > 20) { + statLatencyHistory.value = statLatencyHistory.value.takeLast(20) + } + + return report + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt index fbedc0898ac..a451e605966 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt @@ -25,11 +25,12 @@ import java.io.File * * `Call` declares `val state = CallState(client, this, user, scope)` which passes * `this` to [CallState]. CallState constructs [io.getstream.video.android.core.sorting.SortedParticipantsState], - * whose `init` launches `scope.launch { call.events.collect { ... } }`. Kotlin - * runs field initializers in textual order, so if `events` is declared after - * `state`, the launched coroutine reads `call.events` while the field is still - * null and NPEs into the scope's CoroutineExceptionHandler on production - * dispatchers (Default/Main/IO). + * whose `init` launches `scope.launch { call.events.collect { ... } }`. The + * `events` flow is owned by `eventManager` (CallEventManager) and `Call.events` + * is assigned from it. Kotlin runs field initializers in textual order, so if + * `eventManager` is declared after `state`, the launched coroutine reads + * `call.events` while the backing field is still null and NPEs into the scope's + * CoroutineExceptionHandler on production dispatchers (Default/Main/IO). * * The full test suite uses [kotlinx.coroutines.test.UnconfinedTestDispatcher], * which captures the NPE silently in the scope's exception handler — no @@ -49,15 +50,20 @@ class CallFieldDeclarationOrderTest { ).readText() val lines = callSource.lines() + val eventManagerLine = lines.indexOfFirst { + it.contains("val eventManager = CallEventManager(") + } val eventsLine = lines.indexOfFirst { - it.contains("val events = MutableSharedFlow(") + it.contains("val events: MutableSharedFlow = eventManager.events") } val stateLine = lines.indexOfFirst { it.contains("val state = CallState(") } + assertThat(eventManagerLine).isGreaterThan(-1) assertThat(eventsLine).isGreaterThan(-1) assertThat(stateLine).isGreaterThan(-1) + assertThat(eventManagerLine).isLessThan(stateLine) assertThat(eventsLine).isLessThan(stateLine) } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt index 2ea125779a1..5d6934b7899 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt @@ -38,29 +38,41 @@ class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { private fun Call.injectMockNetwork(connected: Boolean = true) { val mockNetwork = mockk(relaxed = true) every { mockNetwork.isConnected() } returns connected - val field = Call::class.java.getDeclaredField("network\$delegate") + val monitorField = Call::class.java.getDeclaredField("connectivityMonitor") + monitorField.isAccessible = true + val monitor = monitorField.get(this) + val field = monitor.javaClass.getDeclaredField("network\$delegate") field.isAccessible = true - field.set(this, lazyOf(mockNetwork)) + field.set(monitor, lazyOf(mockNetwork)) + } + + private fun Call.reconnector(): Any { + val field = Call::class.java.getDeclaredField("reconnector") + field.isAccessible = true + return field.get(this) } @Suppress("UNCHECKED_CAST") private fun Call.getFailedSfuIds(): MutableSet { - val field = Call::class.java.getDeclaredField("failedSfuIds") + val reconnector = reconnector() + val field = reconnector.javaClass.getDeclaredField("failedSfuIds") field.isAccessible = true - return field.get(this) as MutableSet + return field.get(reconnector) as MutableSet } private fun Call.invokeAddFailedSfuId(sfuId: String) { - val method = Call::class.java.getDeclaredMethod("addFailedSfuId", String::class.java) + val reconnector = reconnector() + val method = reconnector.javaClass.getDeclaredMethod("addFailedSfuId", String::class.java) method.isAccessible = true - method.invoke(this, sfuId) + method.invoke(reconnector, sfuId) } private fun Call.invokeGetFailedSfuIdsSnapshot(): List { - val method = Call::class.java.getDeclaredMethod("getFailedSfuIdsSnapshot") + val reconnector = reconnector() + val method = reconnector.javaClass.getDeclaredMethod("getFailedSfuIdsSnapshot") method.isAccessible = true @Suppress("UNCHECKED_CAST") - return method.invoke(this) as List + return method.invoke(reconnector) as List } @Test diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt index c963c7f0aab..48dce5813ca 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt @@ -46,9 +46,12 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { private fun Call.injectMockNetwork(connected: Boolean = true) { val mockNetwork = mockk(relaxed = true) every { mockNetwork.isConnected() } returns connected - val field = Call::class.java.getDeclaredField("network\$delegate") + val monitorField = Call::class.java.getDeclaredField("connectivityMonitor") + monitorField.isAccessible = true + val monitor = monitorField.get(this) + val field = monitor.javaClass.getDeclaredField("network\$delegate") field.isAccessible = true - field.set(this, lazyOf(mockNetwork)) + field.set(monitor, lazyOf(mockNetwork)) } private fun stubSessionForReconnect(sessionMock: RtcSession) { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt index b5378fcaa08..41b7e59f34f 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt @@ -47,9 +47,12 @@ class ReconnectSessionIdTest : IntegrationTestBase() { private fun Call.injectMockNetwork(connected: Boolean = true) { val mockNetwork = mockk(relaxed = true) every { mockNetwork.isConnected() } returns connected - val field = Call::class.java.getDeclaredField("network\$delegate") + val monitorField = Call::class.java.getDeclaredField("connectivityMonitor") + monitorField.isAccessible = true + val monitor = monitorField.get(this) + val field = monitor.javaClass.getDeclaredField("network\$delegate") field.isAccessible = true - field.set(this, lazyOf(mockNetwork)) + field.set(monitor, lazyOf(mockNetwork)) } @Test From 713958c7ca7307617e67d09748d535e8f78f3f4e Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Tue, 21 Jul 2026 17:02:49 +0530 Subject: [PATCH 02/10] fix(core): remove duplicated join preflight from Call facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Call.join() ran join analytics, permission checks, and the guest-token wait before delegating to CallJoinCoordinator.join(), which performed the exact same preflight — so every join() executed it twice. Make the facade a pure delegation so the preflight runs only once in the coordinator. Co-authored-by: Cursor --- .../io/getstream/video/android/core/Call.kt | 39 ++++--------------- 1 file changed, 8 insertions(+), 31 deletions(-) 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 5b35894c44d..c4b754bf9d8 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 @@ -388,37 +388,14 @@ public class Call( notify: Boolean = false, 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" - } - val permissionPass = - clientImpl.permissionCheck.checkAndroidPermissionsGroup(clientImpl.context, this) - // Check android permissions and log a warning to make sure developers requested adequate permissions prior to using the call. - if (!permissionPass.first) { - logger.w { - "\n[Call.join()] called without having the required permissions.\n" + - "This will work only if you have [runForegroundServiceForCalls = false] in the StreamVideoBuilder.\n" + - "The reason is that [Call.join()] will by default start an ongoing call foreground service,\n" + - "To start this service and send the appropriate audio/video tracks the permissions are required,\n" + - "otherwise the service will fail to start, resulting in a crash.\n" + - "You can re-define your permissions and their expected state by overriding the [permissionCheck] in [StreamVideoBuilder]\n" - } - } - // if we are a guest user, make sure we wait for the token before running the join flow - clientImpl.guestUserJob?.await() - - return joinCoordinator.join( - create, - createOptions, - ring, - notify, - hintHighScaleLivestreamPublisher, - callJoinInterceptor, - ) - } + ): Result = joinCoordinator.join( + create, + createOptions, + ring, + notify, + hintHighScaleLivestreamPublisher, + callJoinInterceptor, + ) suspend fun joinAndRing( members: List, From 74dd015067b08c78f8735214ed41db59ff9fb527 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Thu, 23 Jul 2026 15:57:00 +0530 Subject: [PATCH 03/10] test(core): add unit tests for Call decomposition components Add JVM unit tests for the extracted Call collaborators (CallApiClient, CallEventManager, CallSessionManager, CallRenderer, CallMediaManager) to raise coverage on the refactor's new code toward the SonarCloud gate. Co-authored-by: Cursor --- .../core/call/components/CallApiClientTest.kt | 305 ++++++++++++++++++ .../call/components/CallEventManagerTest.kt | 146 +++++++++ .../call/components/CallMediaManagerTest.kt | 168 ++++++++++ .../core/call/components/CallRendererTest.kt | 139 ++++++++ .../call/components/CallSessionManagerTest.kt | 92 ++++++ 5 files changed, 850 insertions(+) create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt new file mode 100644 index 00000000000..e3ab09431c6 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt @@ -0,0 +1,305 @@ +/* + * 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.call.components + +import com.google.common.truth.Truth.assertThat +import io.getstream.android.video.generated.models.GetCallResponse +import io.getstream.android.video.generated.models.GetOrCreateCallResponse +import io.getstream.android.video.generated.models.GoLiveResponse +import io.getstream.android.video.generated.models.MemberRequest +import io.getstream.android.video.generated.models.StartHLSBroadcastingResponse +import io.getstream.android.video.generated.models.StopLiveResponse +import io.getstream.android.video.generated.models.UpdateCallResponse +import io.getstream.result.Result +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.model.RejectReason +import io.getstream.video.android.core.model.SortField +import io.getstream.video.android.core.recording.RecordingType +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for [CallApiClient], the stateless façade over the coordinator (REST) + * endpoints. Every method must delegate to [StreamVideoClient] and, where relevant, + * update [CallState] from the response. + */ +class CallApiClientTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var clientImpl: StreamVideoClient + private lateinit var state: CallState + private lateinit var call: Call + private lateinit var apiClient: CallApiClient + + @Before + fun setup() { + clientImpl = mockk(relaxed = true) + state = mockk(relaxed = true) + call = mockk(relaxed = true) + + every { call.type } returns "default" + every { call.id } returns "call-id" + every { call.clientImpl } returns clientImpl + every { call.state } returns state + every { call.scope } returns testScope + every { call.sessionId } returns "session-id" + + // Response-processing endpoints return Success so the onSuccess/state-update + // branches are exercised. + coEvery { + clientImpl.getCall(any(), any()) + } returns Result.Success(mockk(relaxed = true)) + coEvery { + clientImpl.getOrCreateCall(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } returns Result.Success(mockk(relaxed = true)) + coEvery { + clientImpl.getOrCreateCallFullMembers(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } returns Result.Success(mockk(relaxed = true)) + coEvery { + clientImpl.updateCall(any(), any(), any()) + } returns Result.Success(mockk(relaxed = true)) + coEvery { + clientImpl.goLive(any(), any(), any(), any(), any()) + } returns Result.Success(mockk(relaxed = true)) + coEvery { + clientImpl.stopLive(any(), any()) + } returns Result.Success(mockk(relaxed = true)) + coEvery { + clientImpl.startBroadcasting(any(), any()) + } returns Result.Success(mockk(relaxed = true)) + + apiClient = CallApiClient(call) + } + + @Test + fun `get delegates and updates state`() = runTest(testDispatcher) { + val result = apiClient.get() + + assertThat(result).isInstanceOf(Result.Success::class.java) + coVerify { clientImpl.getCall("default", "call-id") } + verify { state.updateFromResponse(any()) } + } + + @Test + fun `create without members uses getOrCreateCall`() = runTest(testDispatcher) { + apiClient.create(memberIds = listOf("u1"), ring = false, notify = false) + + coVerify { + clientImpl.getOrCreateCall(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } + verify { state.updateFromResponse(any()) } + } + + @Test + fun `create with members uses full-members endpoint`() = runTest(testDispatcher) { + apiClient.create(members = listOf(MemberRequest(userId = "u1"))) + + coVerify { + clientImpl.getOrCreateCallFullMembers(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun `create with ring registers an outgoing ringing call`() = runTest(testDispatcher) { + apiClient.create(ring = true) + + coVerify { call.client.state.addRingingCall(any(), any()) } + } + + @Test + fun `update delegates and refreshes state`() = runTest(testDispatcher) { + apiClient.update(custom = mapOf("k" to "v")) + + coVerify { clientImpl.updateCall(eq("default"), eq("call-id"), any()) } + verify { state.updateFromResponse(any()) } + } + + @Test + fun `pin and unpin for everyone delegate`() = runTest(testDispatcher) { + apiClient.pinForEveryone("s1", "u1") + apiClient.unpinForEveryone("s1", "u1") + + coVerify { clientImpl.pinForEveryone("default", "call-id", "s1", "u1") } + coVerify { clientImpl.unpinForEveryone("default", "call-id", "s1", "u1") } + } + + @Test + fun `sendReaction delegates`() = runTest(testDispatcher) { + apiClient.sendReaction(type = "reaction", emoji = ":like:") + coVerify { clientImpl.sendReaction("default", "call-id", "reaction", ":like:", null) } + } + + @Test + fun `queryMembers delegates`() = runTest(testDispatcher) { + apiClient.queryMembers( + filter = mapOf("k" to "v"), + sort = listOf(SortField.Desc("created_at")), + ) + coVerify { + clientImpl.queryMembersInternal( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `mute helpers build the correct requests`() = runTest(testDispatcher) { + apiClient.muteAllUsers() + apiClient.muteUser("u1") + apiClient.muteUsers(listOf("u1", "u2")) + + coVerify(exactly = 3) { clientImpl.muteUsers("default", "call-id", any()) } + } + + @Test + fun `live helpers delegate and update state`() = runTest(testDispatcher) { + apiClient.goLive(startRecording = true) + apiClient.stopLive() + + coVerify { clientImpl.goLive("default", "call-id", any(), any(), any()) } + coVerify { clientImpl.stopLive("default", "call-id") } + verify { state.updateFromResponse(any()) } + verify { state.updateFromResponse(any()) } + } + + @Test + fun `hls helpers delegate`() = runTest(testDispatcher) { + apiClient.startHLS() + apiClient.stopHLS() + + coVerify { clientImpl.startBroadcasting("default", "call-id") } + coVerify { clientImpl.stopBroadcasting("default", "call-id") } + } + + @Test + fun `event and permission helpers delegate`() = runTest(testDispatcher) { + apiClient.sendCustomEvent(mapOf("k" to "v")) + apiClient.requestPermissions("send-audio", "send-video") + + coVerify { clientImpl.sendCustomEvent("default", "call-id", any()) } + coVerify { + clientImpl.requestPermissions( + "default", + "call-id", + listOf("send-audio", "send-video"), + ) + } + } + + @Test + fun `recording helpers delegate`() = runTest(testDispatcher) { + apiClient.startRecording(RecordingType.Composite) + apiClient.stopRecording(RecordingType.Composite) + apiClient.listRecordings("s1") + + coVerify { + clientImpl.startRecording("default", "call-id", recordingType = RecordingType.Composite) + } + coVerify { clientImpl.stopRecording("default", "call-id", RecordingType.Composite) } + coVerify { clientImpl.listRecordings("default", "call-id", "s1") } + } + + @Test + fun `moderation helpers delegate`() = runTest(testDispatcher) { + apiClient.blockUser("u1") + apiClient.kickUser("u2", block = true) + apiClient.removeMembers(listOf("u3")) + apiClient.updateMembers(listOf(MemberRequest(userId = "u4"))) + + coVerify { clientImpl.blockUser("default", "call-id", "u1") } + coVerify { clientImpl.kickUser("default", "call-id", "u2", true) } + coVerify(exactly = 2) { clientImpl.updateMembers("default", "call-id", any()) } + } + + @Test + fun `permission grant and revoke delegate`() = runTest(testDispatcher) { + apiClient.grantPermissions("u1", listOf("send-audio")) + apiClient.revokePermissions("u1", listOf("send-audio")) + + coVerify(exactly = 2) { clientImpl.updateUserPermissions("default", "call-id", any()) } + } + + @Test + fun `ringing helpers delegate`() = runTest(testDispatcher) { + apiClient.ring() + apiClient.notify() + apiClient.accept() + apiClient.reject(RejectReason.Cancel) + + coVerify { clientImpl.ring("default", "call-id") } + coVerify { clientImpl.notify("default", "call-id") } + coVerify { clientImpl.accept("default", "call-id") } + coVerify { clientImpl.reject("default", "call-id", RejectReason.Cancel) } + } + + @Test + fun `accept marks the call accepted on this device`() = runTest(testDispatcher) { + apiClient.accept() + verify { state.acceptedOnThisDevice = true } + } + + @Test + fun `transcription and closed-caption helpers delegate`() = runTest(testDispatcher) { + apiClient.startTranscription() + apiClient.stopTranscription() + apiClient.listTranscription() + apiClient.startClosedCaptions() + apiClient.stopClosedCaptions() + + coVerify { clientImpl.startTranscription("default", "call-id") } + coVerify { clientImpl.stopTranscription("default", "call-id") } + coVerify { clientImpl.listTranscription("default", "call-id") } + coVerify { clientImpl.startClosedCaptions("default", "call-id") } + coVerify { clientImpl.stopClosedCaptions("default", "call-id") } + } + + @Test + fun `collectUserFeedback delegates on the call scope`() = runTest(testDispatcher) { + apiClient.collectUserFeedback(rating = 5, reason = "great") + advanceUntilIdle() + + coVerify { + clientImpl.collectFeedback( + callType = "default", + id = "call-id", + sessionId = "session-id", + rating = 5, + reason = "great", + custom = null, + ) + } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt new file mode 100644 index 00000000000..ba77a461bc1 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt @@ -0,0 +1,146 @@ +/* + * 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.call.components + +import com.google.common.truth.Truth.assertThat +import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.base.DispatcherRule +import io.getstream.video.android.core.events.GoAwayEvent +import io.getstream.video.android.core.events.SFUConnectedEvent +import io.getstream.video.android.core.events.VideoEventListener +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +/** + * Unit tests for [CallEventManager] which owns the event pipeline for a [Call]: + * the shared events flow, legacy subscriptions and dispatch of incoming events. + */ +class CallEventManagerTest { + + @get:Rule + val dispatcherRule = DispatcherRule() + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val call = mockk(relaxed = true).also { + every { it.type } returns "default" + every { it.id } returns "call-id" + every { it.scope } returns testScope + } + + private fun manager() = CallEventManager(call) + + @Test + fun `subscribe without filter receives every fired event`() { + val manager = manager() + val received = mutableListOf() + manager.subscribe { received.add(it) } + + val event = mockk(relaxed = true) + manager.fireEvent(event) + + assertThat(received).containsExactly(event) + } + + @Test + fun `subscribeFor only receives matching event types`() { + val manager = manager() + val received = mutableListOf() + manager.subscribeFor(GoAwayEvent::class.java) { received.add(it) } + + val matching = mockk(relaxed = true) + val nonMatching = mockk(relaxed = true) + + manager.fireEvent(nonMatching) + assertThat(received).isEmpty() + + manager.fireEvent(matching) + assertThat(received).containsExactly(matching) + } + + @Test + fun `unsubscribe stops future delivery`() { + val manager = manager() + val received = mutableListOf() + val listener = VideoEventListener { received.add(it) } + val subscription = manager.subscribe(listener) + + manager.unsubscribe(subscription) + manager.fireEvent(mockk(relaxed = true)) + + assertThat(received).isEmpty() + } + + @Test + fun `disposed subscriptions do not receive events`() { + val manager = manager() + val received = mutableListOf() + val subscription = manager.subscribe { received.add(it) } + subscription.dispose() + + manager.fireEvent(mockk(relaxed = true)) + + assertThat(received).isEmpty() + } + + @Test + fun `fireEvent emits to the shared events flow`() = runTest(testDispatcher) { + val manager = manager() + val event = mockk(relaxed = true) + + val emitted = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + manager.events.collect { emitted.add(it) } + } + + manager.fireEvent(event) + advanceUntilIdle() + + assertThat(emitted).contains(event) + } + + @Test + fun `handleEvent triggers migrate on GoAwayEvent`() = runTest(testDispatcher) { + val manager = manager() + + manager.handleEvent(mockk(relaxed = true)) + advanceUntilIdle() + + coVerify(exactly = 1) { call.migrate() } + } + + @Test + fun `handleEvent ignores unrelated events`() = runTest(testDispatcher) { + val manager = manager() + + manager.handleEvent(mockk(relaxed = true)) + advanceUntilIdle() + + coVerify(exactly = 0) { call.migrate() } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt new file mode 100644 index 00000000000..648fbe09700 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt @@ -0,0 +1,168 @@ +/* + * 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.call.components + +import android.content.Intent +import com.google.common.truth.Truth.assertThat +import io.getstream.android.video.generated.models.CallSettingsResponse +import io.getstream.android.video.generated.models.OwnCapability +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.MediaManagerImpl +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.webrtc.audio.JavaAudioDeviceModule.AudioSamples + +/** + * Unit tests for [CallMediaManager], which owns the media pipeline: the peer-connection + * factory lifecycle, screen sharing, settings-driven device init and audio processing. + * + * The [MediaManagerImpl] is injected through [Call.testInstanceProvider] so no real + * WebRTC / native resources are created. + */ +class CallMediaManagerTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var clientImpl: StreamVideoClient + private lateinit var state: CallState + private lateinit var sessionFlow: MutableStateFlow + private lateinit var mediaManager: MediaManagerImpl + private lateinit var call: Call + + @Before + fun setup() { + clientImpl = mockk(relaxed = true) + state = mockk(relaxed = true) + sessionFlow = MutableStateFlow(null) + mediaManager = mockk(relaxed = true) + call = mockk(relaxed = true) + + every { call.type } returns "default" + every { call.id } returns "call-id" + every { call.clientImpl } returns clientImpl + every { call.state } returns state + every { call.scope } returns testScope + every { call.session } returns sessionFlow + + Call.testInstanceProvider.mediaManagerCreator = { mediaManager } + } + + @After + fun tearDown() { + Call.testInstanceProvider.mediaManagerCreator = null + } + + private fun manager() = CallMediaManager(call) + + @Test + fun `startScreenSharing enables screen share when the capability is granted`() { + every { state.ownCapabilities } returns MutableStateFlow(listOf(OwnCapability.Screenshare)) + + manager().startScreenSharing(mockk(relaxed = true), includeAudio = true) + + verify { mediaManager.screenShare.enable(any(), any(), any()) } + } + + @Test + fun `startScreenSharing is ignored without the screenshare capability`() { + every { state.ownCapabilities } returns MutableStateFlow(emptyList()) + + manager().startScreenSharing(mockk(relaxed = true)) + + verify(exactly = 0) { mediaManager.screenShare.enable(any(), any(), any()) } + } + + @Test + fun `stopScreenSharing disables screen share`() { + manager().stopScreenSharing() + verify { mediaManager.screenShare.disable(fromUser = true) } + } + + @Test + fun `cleanup delegates to the media manager`() { + manager().cleanup() + verify { mediaManager.cleanup() } + } + + @Test + fun `recreatePeerConnectionFactory is a no-op when no factory exists`() { + // No factory created yet -> must not throw and nothing to dispose. + manager().recreatePeerConnectionFactory() + } + + @Test + fun `recreateFactoryAndAudioTracks disposes existing tracks and sources`() { + manager().recreateFactoryAndAudioTracks() + verify { mediaManager.disposeTracksAndSources() } + } + + @Test + fun `ensureFactoryMatchesAudioProfile returns early when no factory exists`() { + // Nothing to compare against -> must not throw. + manager().ensureFactoryMatchesAudioProfile() + } + + @Test + fun `updateMediaManagerFromSettings does not crash for already-selected devices`() { + val settings = mockk(relaxed = true) + manager().updateMediaManagerFromSettings(settings) + } + + @Test + fun `processAudioSample forwards samples to the sound processor`() { + val samples = mockk(relaxed = true) + every { samples.data } returns ByteArray(8) + // Should not throw. + manager().processAudioSample(samples) + } + + @Test + fun `audio-processing toggles delegate to the injected factory`() { + val factory = mockk(relaxed = true) + every { factory.isAudioProcessingEnabled() } returns true + every { factory.toggleAudioProcessing() } returns false + + val manager = manager() + manager.peerConnectionFactory = factory + + assertThat(manager.peerConnectionFactory).isSameInstanceAs(factory) + assertThat(manager.isAudioProcessingEnabled()).isTrue() + manager.setAudioProcessingEnabled(true) + assertThat(manager.toggleAudioProcessing()).isFalse() + + verify { factory.isAudioProcessingEnabled() } + verify { factory.setAudioProcessingEnabled(true) } + verify { factory.toggleAudioProcessing() } + } + + @Test + fun `localMicrophoneAudioLevel is exposed`() { + assertThat(manager().localMicrophoneAudioLevel.value).isEqualTo(0f) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt new file mode 100644 index 00000000000..abab96c6fff --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt @@ -0,0 +1,139 @@ +/* + * 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.call.components + +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.model.PreferredVideoResolution +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test +import stream.video.sfu.models.TrackType + +/** + * Unit tests for [CallRenderer], which binds tracks to renderers and forwards + * visibility / track-dimension and incoming media-quality updates to the session. + */ +class CallRendererTest { + + private val sessionFlow = MutableStateFlow(null) + private val call = mockk(relaxed = true).also { + every { it.type } returns "default" + every { it.id } returns "call-id" + every { it.session } returns sessionFlow + } + + private fun renderer() = CallRenderer(call) + + @Test + fun `setVisibility updates track dimensions with default dimension`() { + val session = mockk(relaxed = true) + sessionFlow.value = session + + renderer().setVisibility("s1", TrackType.TRACK_TYPE_VIDEO, visible = true) + + verify { + session.updateTrackDimensions( + "s1", + TrackType.TRACK_TYPE_VIDEO, + true, + any(), + "s1", + ) + } + } + + @Test + fun `setVisibility with explicit size forwards the requested dimension`() { + val session = mockk(relaxed = true) + sessionFlow.value = session + + renderer().setVisibility( + sessionId = "s1", + trackType = TrackType.TRACK_TYPE_VIDEO, + visible = true, + width = 640, + height = 480, + ) + + verify { + session.updateTrackDimensions("s1", TrackType.TRACK_TYPE_VIDEO, true, any(), "s1") + } + } + + @Test + fun `setVisibility is a no-op when there is no session`() { + sessionFlow.value = null + // Should not throw. + renderer().setVisibility("s1", TrackType.TRACK_TYPE_VIDEO, visible = false) + } + + @Test + fun `setPreferredIncomingVideoResolution forwards overrides`() { + val session = mockk(relaxed = true) + sessionFlow.value = session + + renderer().setPreferredIncomingVideoResolution( + PreferredVideoResolution(width = 1280, height = 720), + sessionIds = listOf("s1"), + ) + + verify { + session.trackOverridesHandler.updateOverrides( + sessionIds = listOf("s1"), + dimensions = any(), + ) + } + } + + @Test + fun `setPreferredIncomingVideoResolution clears overrides when resolution is null`() { + val session = mockk(relaxed = true) + sessionFlow.value = session + + renderer().setPreferredIncomingVideoResolution(null) + + verify { + session.trackOverridesHandler.updateOverrides( + sessionIds = null, + dimensions = null, + ) + } + } + + @Test + fun `setIncomingVideoEnabled forwards visibility overrides`() { + val session = mockk(relaxed = true) + sessionFlow.value = session + + renderer().setIncomingVideoEnabled(enabled = false, sessionIds = listOf("s1")) + + verify { session.trackOverridesHandler.updateOverrides(listOf("s1"), visible = false) } + } + + @Test + fun `setIncomingAudioEnabled returns early when there is no subscriber`() { + val session = mockk(relaxed = true) + every { session.subscriber } returns MutableStateFlow(null) + sessionFlow.value = session + + // No tracks available -> should return without throwing. + renderer().setIncomingAudioEnabled(enabled = true) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt new file mode 100644 index 00000000000..1885921de90 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt @@ -0,0 +1,92 @@ +/* + * 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.call.components + +import com.google.common.truth.Truth.assertThat +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.call.RtcSession +import io.mockk.mockk +import org.junit.Test + +/** + * Unit tests for [CallSessionManager], the single source of truth for the live RTC + * session and the bookkeeping shared across the join / reconnect flows. + */ +class CallSessionManagerTest { + + private val call = mockk(relaxed = true) + + private fun manager() = CallSessionManager(call) + + @Test + fun `session starts empty and can be replaced`() { + val manager = manager() + assertThat(manager.session.value).isNull() + + val session = mockk(relaxed = true) + manager.session.value = session + + assertThat(manager.session.value).isSameInstanceAs(session) + } + + @Test + fun `session and unified session ids are non-blank uuids and distinct`() { + val manager = manager() + + assertThat(manager.sessionId).isNotEmpty() + assertThat(manager.unifiedSessionId).isNotEmpty() + assertThat(manager.sessionId).isNotEqualTo(manager.unifiedSessionId) + } + + @Test + fun `each manager gets its own session ids`() { + assertThat(manager().sessionId).isNotEqualTo(manager().sessionId) + assertThat(manager().unifiedSessionId).isNotEqualTo(manager().unifiedSessionId) + } + + @Test + fun `sessionId is mutable`() { + val manager = manager() + manager.sessionId = "custom-session-id" + assertThat(manager.sessionId).isEqualTo("custom-session-id") + } + + @Test + fun `location defaults to null and is mutable`() { + val manager = manager() + assertThat(manager.location).isNull() + + manager.location = "amsterdam" + assertThat(manager.location).isEqualTo("amsterdam") + } + + @Test + fun `reconnect and timing counters default to zero and are mutable`() { + val manager = manager() + assertThat(manager.nonFastReconnectAttempts).isEqualTo(0) + assertThat(manager.connectStartTime).isEqualTo(0L) + assertThat(manager.reconnectStartTime).isEqualTo(0L) + + manager.nonFastReconnectAttempts += 3 + manager.connectStartTime = 111L + manager.reconnectStartTime = 222L + + assertThat(manager.nonFastReconnectAttempts).isEqualTo(3) + assertThat(manager.connectStartTime).isEqualTo(111L) + assertThat(manager.reconnectStartTime).isEqualTo(222L) + } +} From 49ecb70a8e0efef66b600cd3ed867cec54a58ea8 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Thu, 23 Jul 2026 17:20:42 +0530 Subject: [PATCH 04/10] test(core): add unit tests for join/reconnect/connectivity/ice components Broaden new-code coverage for the extracted Call components: exercise the CallJoinCoordinator retry loop and join-and-ring flow (via the RtcSession test factory), the CallConnectivityMonitor reconnect/leave listener, the reachable CallReconnector state-machine branches, the CallIceConnectionMonitor restart paths, plus additional CallMediaManager (monitorHeadset, not-selected devices) and CallApiClient (ring request, ringing create) cases. Co-authored-by: Cursor --- .../core/call/components/CallApiClientTest.kt | 20 ++ .../components/CallConnectivityMonitorTest.kt | 147 +++++++++ .../CallIceConnectionMonitorTest.kt | 115 +++++++ .../components/CallJoinCoordinatorTest.kt | 281 ++++++++++++++++++ .../call/components/CallMediaManagerTest.kt | 45 +++ .../call/components/CallReconnectorTest.kt | 163 ++++++++++ 6 files changed, 771 insertions(+) create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt index e3ab09431c6..090dce2864e 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt @@ -21,6 +21,7 @@ import io.getstream.android.video.generated.models.GetCallResponse import io.getstream.android.video.generated.models.GetOrCreateCallResponse import io.getstream.android.video.generated.models.GoLiveResponse import io.getstream.android.video.generated.models.MemberRequest +import io.getstream.android.video.generated.models.RingCallRequest import io.getstream.android.video.generated.models.StartHLSBroadcastingResponse import io.getstream.android.video.generated.models.StopLiveResponse import io.getstream.android.video.generated.models.UpdateCallResponse @@ -265,6 +266,25 @@ class CallApiClientTest { coVerify { clientImpl.reject("default", "call-id", RejectReason.Cancel) } } + @Test + fun `ring with an explicit request delegates`() = runTest(testDispatcher) { + val request = RingCallRequest(video = true) + apiClient.ring(request) + coVerify { clientImpl.ring("default", "call-id", request) } + } + + @Test + fun `create with members and ring registers an outgoing ringing call`() = runTest( + testDispatcher, + ) { + apiClient.create(members = listOf(MemberRequest(userId = "u1")), ring = true) + + coVerify { + clientImpl.getOrCreateCallFullMembers(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) + } + coVerify { call.client.state.addRingingCall(any(), any()) } + } + @Test fun `accept marks the call accepted on this device`() = runTest(testDispatcher) { apiClient.accept() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.kt new file mode 100644 index 00000000000..064cb53e564 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.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.call.components + +import com.google.common.truth.Truth.assertThat +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule +import io.getstream.video.android.core.internal.network.NetworkStateProvider +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import stream.video.sfu.models.WebsocketReconnectStrategy + +/** + * Tests [CallConnectivityMonitor]: network subscription forwarding, the reconnect + * strategy chosen on reconnection, and the delayed leave when the device stays offline. + */ +class CallConnectivityMonitorTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var network: NetworkStateProvider + private lateinit var clientImpl: StreamVideoClient + private lateinit var state: CallState + private lateinit var connectionFlow: MutableStateFlow + private lateinit var call: Call + + @Before + fun setup() { + network = mockk(relaxed = true) + clientImpl = mockk(relaxed = true) + state = mockk(relaxed = true) + connectionFlow = MutableStateFlow(RealtimeConnection.Reconnecting) + call = mockk(relaxed = true) + + val coordinatorModule = mockk(relaxed = true) + every { coordinatorModule.networkStateProvider } returns network + every { clientImpl.coordinatorConnectionModule } returns coordinatorModule + every { clientImpl.leaveAfterDisconnectSeconds } returns 1L + every { network.isConnected() } returns true + + every { call.type } returns "default" + every { call.id } returns "call-id" + every { call.clientImpl } returns clientImpl + every { call.scope } returns testScope + every { call.state } returns state + every { call.reconnectDeadlineMillis } returns 10_000 + every { state.connection } returns connectionFlow + } + + private fun monitor() = CallConnectivityMonitor(call) + + private fun listenerOf(monitor: CallConnectivityMonitor): NetworkStateProvider.NetworkStateListener { + val field = CallConnectivityMonitor::class.java.getDeclaredField("listener") + field.isAccessible = true + return field.get(monitor) as NetworkStateProvider.NetworkStateListener + } + + @Test + fun `subscribe unsubscribe and isConnected forward to the network provider`() { + val monitor = monitor() + + monitor.subscribe() + monitor.unsubscribe() + val connected = monitor.isConnected() + + verify { network.subscribe(any()) } + verify { network.unsubscribe(any()) } + assertThat(connected).isTrue() + } + + @Test + fun `first connection with no prior disconnect rejoins`() = runTest(testDispatcher) { + val listener = listenerOf(monitor()) + + listener.onConnected() + advanceUntilIdle() + + coVerify { + call.reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, any()) + } + } + + @Test + fun `reconnection soon after a disconnect uses fast reconnect`() = runTest(testDispatcher) { + val listener = listenerOf(monitor()) + + listener.onDisconnected() + listener.onConnected() + advanceUntilIdle() + + coVerify { + call.reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, any()) + } + } + + @Test + fun `staying offline past the timeout leaves the call`() = runTest(testDispatcher) { + val listener = listenerOf(monitor()) + + listener.onDisconnected() + advanceTimeBy(2_000) + advanceUntilIdle() + + verify { call.leave(any()) } + } + + @Test + fun `reconnecting before the timeout does not leave the call`() = runTest(testDispatcher) { + val listener = listenerOf(monitor()) + + listener.onDisconnected() + connectionFlow.value = RealtimeConnection.Connected + advanceTimeBy(2_000) + advanceUntilIdle() + + verify(exactly = 0) { call.leave(any()) } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt new file mode 100644 index 00000000000..c5d2828646a --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt @@ -0,0 +1,115 @@ +/* + * 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.call.components + +import io.getstream.video.android.core.Call +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.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.webrtc.PeerConnection + +/** + * Tests [CallIceConnectionMonitor], which watches the publisher / subscriber ICE states + * and triggers an ICE restart when a connection FAILS or is DISCONNECTED. + */ +class CallIceConnectionMonitorTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var session: RtcSession + private lateinit var publisher: Publisher + private lateinit var subscriber: Subscriber + private lateinit var sessionFlow: MutableStateFlow + private lateinit var call: Call + + @Before + fun setup() { + session = mockk(relaxed = true) + publisher = mockk(relaxed = true) + subscriber = mockk(relaxed = true) + sessionFlow = MutableStateFlow(session) + call = mockk(relaxed = true) + + every { call.type } returns "default" + every { call.id } returns "call-id" + every { call.scope } returns testScope + every { call.session } returns sessionFlow + every { session.publisher } returns MutableStateFlow(publisher) + every { session.subscriber } returns MutableStateFlow(subscriber) + } + + private fun monitor() = CallIceConnectionMonitor(call) + + @Test + fun `failed publisher ice state triggers an ice restart`() = runTest(testDispatcher) { + every { publisher.iceState } returns + MutableStateFlow(PeerConnection.IceConnectionState.FAILED) + every { subscriber.iceState } returns + MutableStateFlow(PeerConnection.IceConnectionState.CONNECTED) + + val monitor = monitor() + monitor.start() + advanceUntilIdle() + + coVerify { publisher.connection.restartIce() } + monitor.stop() + } + + @Test + fun `disconnected subscriber ice state requests a subscriber ice restart`() = runTest( + testDispatcher, + ) { + every { publisher.iceState } returns + MutableStateFlow(PeerConnection.IceConnectionState.CONNECTED) + every { subscriber.iceState } returns + MutableStateFlow(PeerConnection.IceConnectionState.DISCONNECTED) + + val monitor = monitor() + monitor.start() + advanceUntilIdle() + + coVerify { session.requestSubscriberIceRestart() } + monitor.stop() + } + + @Test + fun `healthy ice states do not trigger restarts`() = runTest(testDispatcher) { + every { publisher.iceState } returns + MutableStateFlow(PeerConnection.IceConnectionState.CONNECTED) + every { subscriber.iceState } returns + MutableStateFlow(PeerConnection.IceConnectionState.CONNECTED) + + val monitor = monitor() + monitor.start() + advanceUntilIdle() + + coVerify(exactly = 0) { publisher.connection.restartIce() } + coVerify(exactly = 0) { session.requestSubscriberIceRestart() } + monitor.stop() + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt new file mode 100644 index 00000000000..3f8250de276 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -0,0 +1,281 @@ +/* + * 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.call.components + +import com.google.common.truth.Truth.assertThat +import io.getstream.android.video.generated.models.JoinCallResponse +import io.getstream.android.video.generated.models.RingCallRequest +import io.getstream.android.video.generated.models.RingCallResponse +import io.getstream.result.Error +import io.getstream.result.Result.Failure +import io.getstream.result.Result.Success +import io.getstream.video.android.core.Call +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.model.JoinAnalyticsModel +import io.getstream.video.android.core.analytics.call.observer.model.JoinReason +import io.getstream.video.android.core.base.DispatcherRule +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.SfuConnectFailureCause +import io.getstream.video.android.core.call.SfuConnectionResult +import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule +import io.getstream.video.android.core.internal.network.NetworkStateProvider +import io.getstream.video.android.model.User +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.RelaxedMockK +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * Tests the join orchestration extracted into [CallJoinCoordinator]: the public + * [Call.join] retry loop, join-and-ring, the coordinator's own join request, and the + * permanent-vs-transient error handling. Exercised through the [Call] facade with the + * RtcSession injected via [Call.unitTestRtcSessionFactory]. + */ +class CallJoinCoordinatorTest { + + @get:Rule + val dispatcherRule = DispatcherRule() + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + @RelaxedMockK + private lateinit var mockClientImpl: StreamVideoClient + + @RelaxedMockK + private lateinit var mockSession: RtcSession + + private lateinit var mockStreamVideo: StreamVideo + private lateinit var mockJoinResponse: JoinCallResponse + private lateinit var call: Call + + @Before + fun setup() { + MockKAnnotations.init(this, relaxUnitFun = true) + + mockStreamVideo = mockk(relaxed = true) + StreamVideo.install(mockStreamVideo) + + val mockNetworkStateProvider = mockk(relaxed = true) + every { mockNetworkStateProvider.isConnected() } returns true + val mockCoordinatorModule = mockk(relaxed = true) + every { mockCoordinatorModule.networkStateProvider } returns mockNetworkStateProvider + + every { mockClientImpl.coordinatorConnectionModule } returns mockCoordinatorModule + every { mockClientImpl.scope } returns testScope as CoroutineScope + every { mockClientImpl.leaveAfterDisconnectSeconds } returns 120L + every { mockClientImpl.apiKey } returns "test-api-key" + coEvery { mockClientImpl.getCachedLocation() } returns Success("test-location") + every { + mockClientImpl.permissionCheck.checkAndroidPermissionsGroup(any(), any()) + } returns Pair(true, emptySet()) + + mockJoinResponse = mockk(relaxed = true) + + Call.testInstanceProvider.mediaManagerCreator = { mockk(relaxed = true) } + + call = spyk( + Call( + client = mockClientImpl, + type = "default", + id = "test-call", + user = User(id = "test-user", role = "user"), + ), + ) + repointComponentToSpy("joinCoordinator", call) + call.unitTestRtcSessionFactory = { mockSession } + every { call.monitorSession(any()) } returns Unit + } + + @After + fun tearDown() { + Call.testInstanceProvider.mediaManagerCreator = null + StreamVideo.removeClient() + unmockkAll() + } + + private fun repointComponentToSpy(fieldName: String, spy: Call) { + val field = Call::class.java.getDeclaredField(fieldName) + field.isAccessible = true + val component = field.get(spy) + val callField = component.javaClass.getDeclaredField("call") + callField.isAccessible = true + callField.set(component, spy) + } + + @Test + fun `join succeeds and returns the connected session`() = runTest(testDispatcher) { + coEvery { + call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Success(mockJoinResponse) + coEvery { mockSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success + + val result = call.join() + advanceUntilIdle() + + assertThat(result).isInstanceOf(Success::class.java) + assertThat((result as Success).value).isSameInstanceAs(mockSession) + coVerify { call.monitorSession(mockJoinResponse) } + } + + @Test + fun `join fails permanently on a terminal SFU failure without retrying`() = runTest( + testDispatcher, + ) { + coEvery { + call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Success(mockJoinResponse) + coEvery { mockSession.connectInternal(any(), any()) } returns SfuConnectionResult.Failure( + Exception("permanent auth error"), + cause = SfuConnectFailureCause.TerminalSocketFailure, + ) + + val result = call.join() + advanceUntilIdle() + + assertThat(result).isInstanceOf(Failure::class.java) + assertThat(call.state.connection.value).isInstanceOf(RealtimeConnection.Failed::class.java) + } + + @Test + fun `join retries transient errors and gives up after three attempts`() = runTest( + testDispatcher, + ) { + // "Unable to resolve host" is treated as transient, so the loop retries. + coEvery { + call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Failure(Error.ThrowableError("Unable to resolve host \"sfu\"", Exception("dns"))) + + val result = call.join() + advanceUntilIdle() + + assertThat(result).isInstanceOf(Failure::class.java) + // joinRequest is attempted once per retry (3 attempts total). + coVerify(exactly = 3) { + call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun `join fails when the call is already joined`() = runTest(testDispatcher) { + call.session.value = mockk(relaxed = true) + + val result = call._join(joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt)) + + assertThat(result).isInstanceOf(Failure::class.java) + } + + @Test + fun `join fails when the location cannot be resolved`() = runTest(testDispatcher) { + coEvery { mockClientImpl.getCachedLocation() } returns + Failure(Error.GenericError("no location")) + + val result = call._join(joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt)) + + assertThat(result).isInstanceOf(Failure::class.java) + } + + @Test + fun `isPermanentError treats host-resolution failures as transient`() = runTest( + testDispatcher, + ) { + val coordinator = coordinator() + val transient = Error.ThrowableError("Unable to resolve host", Exception("dns")) + val permanent = Error.GenericError("server error") + + assertThat(coordinator.isPermanentError(transient)).isFalse() + assertThat(coordinator.isPermanentError(permanent)).isTrue() + } + + @Test + fun `joinAndRing joins then rings the members`() = runTest(testDispatcher) { + coEvery { + call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Success(mockJoinResponse) + coEvery { mockSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success + coEvery { call.ring(any()) } returns + Success(mockk(relaxed = true)) + + val result = call.joinAndRing(members = listOf("u1")) + advanceUntilIdle() + + assertThat(result).isInstanceOf(Success::class.java) + coVerify { call.ring(any()) } + } + + @Test + fun `joinAndRing leaves the call when ringing fails`() = runTest(testDispatcher) { + coEvery { + call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Success(mockJoinResponse) + coEvery { mockSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success + coEvery { call.ring(any()) } returns + Failure(Error.GenericError("ring failed")) + every { call.leave(any()) } returns Unit + + val result = call.joinAndRing(members = listOf("u1")) + advanceUntilIdle() + + assertThat(result).isInstanceOf(Failure::class.java) + coVerify { call.leave(any()) } + } + + @Test + fun `joinRequest delegates to the coordinator client`() = runTest(testDispatcher) { + coEvery { + mockClientImpl.joinCall( + any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), + ) + } returns Failure(Error.GenericError("boom")) + + val result = call.joinRequest( + location = "test-location", + joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt), + ) + + assertThat(result).isInstanceOf(Failure::class.java) + coVerify { + mockClientImpl.joinCall( + any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), + ) + } + } + + private fun coordinator(): CallJoinCoordinator { + val field = Call::class.java.getDeclaredField("joinCoordinator") + field.isAccessible = true + return field.get(call) as CallJoinCoordinator + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt index 648fbe09700..9fc4a8e9d11 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt @@ -22,8 +22,10 @@ import io.getstream.android.video.generated.models.CallSettingsResponse import io.getstream.android.video.generated.models.OwnCapability import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.DeviceStatus import io.getstream.video.android.core.MediaManagerImpl import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.mockk.every @@ -32,6 +34,8 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.junit.Test @@ -165,4 +169,45 @@ class CallMediaManagerTest { fun `localMicrophoneAudioLevel is exposed`() { assertThat(manager().localMicrophoneAudioLevel.value).isEqualTo(0f) } + + @Test + fun `updateMediaManagerFromSettings initialises not-yet-selected devices`() = runTest( + testDispatcher, + ) { + every { mediaManager.speaker.status } returns MutableStateFlow(DeviceStatus.NotSelected) + every { mediaManager.camera.status } returns MutableStateFlow(DeviceStatus.NotSelected) + every { mediaManager.microphone.status } returns MutableStateFlow(DeviceStatus.NotSelected) + every { mediaManager.microphone.devices } returns MutableStateFlow(emptyList()) + + manager().updateMediaManagerFromSettings(mockk(relaxed = true)) + advanceUntilIdle() + + verify { mediaManager.speaker.setEnabled(any()) } + verify { mediaManager.camera.setEnabled(any()) } + verify { mediaManager.microphone.setEnabled(any()) } + } + + @Test + fun `monitorHeadset selects a bluetooth headset when available`() = runTest(testDispatcher) { + val bluetooth = mockk(relaxed = true) + every { mediaManager.microphone.devices } returns MutableStateFlow(listOf(bluetooth)) + + manager().updateMediaManagerFromSettings(mockk(relaxed = true)) + advanceUntilIdle() + + verify { mediaManager.microphone.select(bluetooth) } + } + + @Test + fun `monitorHeadset selects a wired headset when no bluetooth is present`() = runTest( + testDispatcher, + ) { + val wired = mockk(relaxed = true) + every { mediaManager.microphone.devices } returns MutableStateFlow(listOf(wired)) + + manager().updateMediaManagerFromSettings(mockk(relaxed = true)) + advanceUntilIdle() + + verify { mediaManager.microphone.select(wired) } + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt new file mode 100644 index 00000000000..9388df04cd3 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt @@ -0,0 +1,163 @@ +/* + * 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.call.components + +import com.google.common.truth.Truth.assertThat +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.call.RtcSession +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import stream.video.sfu.models.WebsocketReconnectStrategy + +/** + * Tests the reconnect state-machine branches of [CallReconnector] that do not require a + * live [RtcSession]: early exits, the DISCONNECT strategy, and the precondition guards + * for REJOIN / MIGRATE. The happy-path reconnect flows are covered by the RTC tests. + */ +class CallReconnectorTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private lateinit var clientImpl: StreamVideoClient + private lateinit var state: CallState + private lateinit var connectionFlow: MutableStateFlow + private lateinit var sessionFlow: MutableStateFlow + private lateinit var call: Call + + @Before + fun setup() { + clientImpl = mockk(relaxed = true) + state = mockk(relaxed = true) + connectionFlow = MutableStateFlow(RealtimeConnection.Reconnecting) + sessionFlow = MutableStateFlow(null) + call = mockk(relaxed = true) + + every { clientImpl.leaveAfterDisconnectSeconds } returns 120L + + every { call.type } returns "default" + every { call.id } returns "call-id" + every { call.clientImpl } returns clientImpl + every { call.scope } returns testScope + every { call.state } returns state + every { call.session } returns sessionFlow + every { call.isDestroyed } returns false + every { call.isNetworkConnected() } returns true + every { call.reconnectDeadlineMillis } returns 60_000 + every { call.location } returns null + every { state.connection } returns connectionFlow + every { state._connection } returns connectionFlow + } + + private fun reconnector() = CallReconnector(call) + + @Test + fun `reconnect is skipped when the call is destroyed`() = runTest(testDispatcher) { + every { call.isDestroyed } returns true + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + "test", + ) + advanceUntilIdle() + + // No leave / failure driven when we bail out immediately. + verify(exactly = 0) { call.leave(any()) } + } + + @Test + fun `reconnect is skipped when already disconnected`() = runTest(testDispatcher) { + connectionFlow.value = RealtimeConnection.Disconnected + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + "test", + ) + advanceUntilIdle() + + verify(exactly = 0) { call.leave(any()) } + } + + @Test + fun `disconnect strategy leaves the call`() = runTest(testDispatcher) { + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_DISCONNECT, + "server-disconnect", + ) + advanceUntilIdle() + + verify { call.leave(any()) } + } + + @Test + fun `rejoin without a location gives up and leaves`() = runTest(testDispatcher) { + every { call.location } returns null + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + "rejoin", + ) + advanceUntilIdle() + + assertThat(connectionFlow.value) + .isInstanceOf(RealtimeConnection.ReconnectingFailed::class.java) + verify { call.leave(any()) } + } + + @Test + fun `fast reconnect without a session gives up and leaves`() = runTest(testDispatcher) { + sessionFlow.value = null + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + "fast", + ) + advanceUntilIdle() + + assertThat(connectionFlow.value) + .isInstanceOf(RealtimeConnection.ReconnectingFailed::class.java) + } + + @Test + fun `failed sfu id bookkeeping is exposed as a snapshot`() { + val reconnector = reconnector() + assertThat(reconnector.getFailedSfuIdsSnapshot()).isEmpty() + reconnector.clearFailedSfuIds() + assertThat(reconnector.getFailedSfuIdsSnapshot()).isEmpty() + } + + @Test + fun `strategy helpers forward to reconnect without throwing`() = runTest(testDispatcher) { + val reconnector = reconnector() + reconnector.fastReconnect("helper") + reconnector.rejoin("helper") + reconnector.migrate() + advanceUntilIdle() + } +} From 8ad52188e0819522f3922d38e3c6b5b36de1df44 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Thu, 23 Jul 2026 17:50:01 +0530 Subject: [PATCH 05/10] test(core): cover reconnector rejoin/migrate and renderer audio paths Add a unitTestRtcSessionFactory seam to CallReconnector's rejoin/migrate so the session-swap, monitor and finalize paths are unit-testable, and add tests for them (success + retry-until-exhausted). Also cover CallRenderer's incoming-audio track walking for all/selected participants. Co-authored-by: Cursor --- .../core/call/components/CallReconnector.kt | 4 +- .../call/components/CallReconnectorTest.kt | 87 +++++++++++++++++++ .../core/call/components/CallRendererTest.kt | 38 ++++++++ 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt index 30a60f8e386..4e1eaf373f4 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt @@ -373,7 +373,7 @@ internal class CallReconnector( ) call.state.removeParticipant(prevSessionId) oldSession.prepareRejoin("rejoin") - val newSession = RtcSession( + val newSession = call.unitTestRtcSessionFactory?.invoke() ?: RtcSession( clientImpl, call.nonFastReconnectAttempts, call.powerManager, @@ -456,7 +456,7 @@ internal class CallReconnector( oldSession.sendCallStats(stats) oldSession.enterMigration() - val newSession = RtcSession( + val newSession = call.unitTestRtcSessionFactory?.invoke() ?: RtcSession( clientImpl, call.nonFastReconnectAttempts, call.powerManager, diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt index 9388df04cd3..2f58cf297f2 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt @@ -17,12 +17,18 @@ package io.getstream.video.android.core.call.components import com.google.common.truth.Truth.assertThat +import io.getstream.android.video.generated.models.JoinCallResponse +import io.getstream.result.Result.Success import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallLeaveReason import io.getstream.video.android.core.CallState import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.StreamVideoClient 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 +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -160,4 +166,85 @@ class CallReconnectorTest { reconnector.migrate() advanceUntilIdle() } + + @Test + fun `rejoin swaps in the new session and monitors it on success`() = runTest(testDispatcher) { + val oldSession = mockk(relaxed = true) + val newSession = mockk(relaxed = true) + val joinResponse = mockk(relaxed = true) + prepareRejoinOrMigrate(oldSession, newSession, joinResponse) + coEvery { newSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + "rejoin", + ) + advanceUntilIdle() + + assertThat(sessionFlow.value).isSameInstanceAs(newSession) + coVerify { call.monitorSession(joinResponse) } + } + + @Test + fun `rejoin failures are retried until the attempts are exhausted`() = runTest(testDispatcher) { + val oldSession = mockk(relaxed = true) + val newSession = mockk(relaxed = true) + val joinResponse = mockk(relaxed = true) + prepareRejoinOrMigrate(oldSession, newSession, joinResponse) + coEvery { newSession.connectInternal(any(), any()) } returns SfuConnectionResult.Failure( + Exception("rejoin failed"), + cause = io.getstream.video.android.core.call.SfuConnectFailureCause.RecoverableSocketFailure, + ) + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + "rejoin", + ) + advanceUntilIdle() + + verify { call.leave(any()) } + } + + @Test + fun `migrate swaps in the new session and finalizes the old one on success`() = runTest( + testDispatcher, + ) { + val oldSession = mockk(relaxed = true) + val newSession = mockk(relaxed = true) + val joinResponse = mockk(relaxed = true) + prepareRejoinOrMigrate(oldSession, newSession, joinResponse) + coEvery { newSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE, + "migrate", + ) + advanceUntilIdle() + + assertThat(sessionFlow.value).isSameInstanceAs(newSession) + coVerify { oldSession.finalizeMigration() } + coVerify { call.monitorSession(joinResponse) } + } + + /** + * Wires up the shared happy-path state a rejoin/migrate needs: a resolvable location, + * an existing (old) session, a stubbed join request and an injected new session. + */ + private fun prepareRejoinOrMigrate( + oldSession: RtcSession, + newSession: RtcSession, + joinResponse: JoinCallResponse, + ) { + every { call.location } returns "test-location" + sessionFlow.value = oldSession + // The old session becomes the new one on every retry, so both need the same stubs. + for (s in listOf(oldSession, newSession)) { + every { s.currentSfuInfo() } returns Triple("prev-session", emptyList(), emptyList()) + every { s.publisher } returns MutableStateFlow(null) + } + coEvery { + call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Success(joinResponse) + every { call.unitTestRtcSessionFactory } returns { newSession } + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt index abab96c6fff..4faa1689964 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt @@ -18,6 +18,9 @@ package io.getstream.video.android.core.call.components import io.getstream.video.android.core.Call import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.connection.Subscriber +import io.getstream.video.android.core.model.AudioTrack +import io.getstream.video.android.core.model.MediaTrack import io.getstream.video.android.core.model.PreferredVideoResolution import io.mockk.every import io.mockk.mockk @@ -25,6 +28,7 @@ import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import org.junit.Test import stream.video.sfu.models.TrackType +import java.util.concurrent.ConcurrentHashMap /** * Unit tests for [CallRenderer], which binds tracks to renderers and forwards @@ -136,4 +140,38 @@ class CallRendererTest { // No tracks available -> should return without throwing. renderer().setIncomingAudioEnabled(enabled = true) } + + @Test + fun `setIncomingAudioEnabled toggles audio for all participants`() { + val audioTrack = mockk(relaxed = true) + sessionFlow.value = sessionWithAudioTrack(audioTrack) + + renderer().setIncomingAudioEnabled(enabled = false) + + verify { audioTrack.enableAudio(false) } + } + + @Test + fun `setIncomingAudioEnabled toggles audio for the requested sessions`() { + val audioTrack = mockk(relaxed = true) + sessionFlow.value = sessionWithAudioTrack(audioTrack) + + renderer().setIncomingAudioEnabled(enabled = true, sessionIds = listOf("s1")) + + verify { audioTrack.enableAudio(true) } + } + + private fun sessionWithAudioTrack(audioTrack: AudioTrack): RtcSession { + val innerTracks = ConcurrentHashMap().apply { + put(TrackType.TRACK_TYPE_AUDIO, audioTrack) + } + val tracks = ConcurrentHashMap>().apply { + put("s1", innerTracks) + } + val subscriber = mockk(relaxed = true) + every { subscriber.tracks } returns tracks + return mockk(relaxed = true).also { + every { it.subscriber } returns MutableStateFlow(subscriber) + } + } } From 7537c761c8f7dbd85c4e755344f8b448d72ec308 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Thu, 30 Jul 2026 18:47:21 +0530 Subject: [PATCH 06/10] refactor(core): decouple Call collaborators from the Call facade Give the Call decomposition components explicit dependencies so they no longer reach into the Call facade: - CallApiClient, CallConnectivityMonitor, CallEventManager, CallIceConnectionMonitor, CallRenderer, CallStatsReporter, CallSessionManager and CallMediaManager now take the granular collaborators they need (type/id/scope/state/session/clientImpl/eglBase) instead of a Call. - Isolate the unavoidable identity hand-offs behind small seams/providers: RingingCallRegistrar for CallApiClient's ring/accept client-state writes, and a lazy () -> Call provider for CallMediaManager's MediaManagerImpl (a public type that requires a Call). - Behaviour is unchanged; component unit tests now construct each collaborator directly without a Call mock. The three orchestrators (CallReconnector, CallJoinCoordinator, CallLifecycleManager) still hold Call and are left for a follow-up. Co-authored-by: Cursor --- .../io/getstream/video/android/core/Call.kt | 108 ++++++++++++------ .../video/android/core/MediaManager.kt | 3 +- .../core/call/components/CallApiClient.kt | 58 ++++++---- .../components/CallConnectivityMonitor.kt | 35 ++++-- .../core/call/components/CallEventManager.kt | 15 ++- .../components/CallIceConnectionMonitor.kt | 23 ++-- .../core/call/components/CallMediaManager.kt | 52 ++++++--- .../core/call/components/CallReconnector.kt | 7 +- .../core/call/components/CallRenderer.kt | 32 ++++-- .../call/components/CallSessionManager.kt | 4 +- .../core/call/components/CallStatsReporter.kt | 25 ++-- .../core/call/components/CallApiClientTest.kt | 29 ++--- .../components/CallConnectivityMonitorTest.kt | 52 ++++++--- .../call/components/CallEventManagerTest.kt | 18 +-- .../CallIceConnectionMonitorTest.kt | 9 +- .../call/components/CallMediaManagerTest.kt | 22 ++-- .../call/components/CallReconnectorTest.kt | 4 +- .../core/call/components/CallRendererTest.kt | 16 +-- .../call/components/CallSessionManagerTest.kt | 5 +- 19 files changed, 314 insertions(+), 203 deletions(-) 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 c4b754bf9d8..86b98e4c433 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 @@ -67,6 +67,7 @@ import io.getstream.video.android.core.call.components.CallReconnector import io.getstream.video.android.core.call.components.CallRenderer import io.getstream.video.android.core.call.components.CallSessionManager import io.getstream.video.android.core.call.components.CallStatsReporter +import io.getstream.video.android.core.call.components.RingingCallRegistrar import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.getstream.video.android.core.call.scope.ScopeProvider import io.getstream.video.android.core.call.scope.ScopeProviderImpl @@ -139,7 +140,7 @@ public class Call( internal val scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) /** Delegate that owns the live RTC session state and reconnect bookkeeping. */ - private val sessionManager = CallSessionManager(this) + private val sessionManager = CallSessionManager() /** Session handles all real time communication for video and audio */ internal val session: MutableStateFlow get() = sessionManager.session @@ -181,8 +182,14 @@ public class Call( // TODO(v2): replace this with a proper dependency injection boundary. internal var unitTestRtcSessionFactory: (() -> RtcSession)? = null + /** Delegate that owns the unified reconnect state machine (fast / rejoin / migrate). */ + private val reconnector = CallReconnector(this) + + /** Delegate that owns leave / end / cleanup teardown and the destroyed flag. */ + private val lifecycle = CallLifecycleManager(this) + /** Delegate that owns the event flow, subscriptions and event dispatch. */ - private val eventManager = CallEventManager(this) + private val eventManager = CallEventManager(type, id, scope, reconnector) // Must be initialized before `state` — CallState → SortedParticipantsState // launches a coroutine that reads `call.events` (leaking-this race). @@ -265,16 +272,53 @@ public class Call( ) /** Delegate that wraps all coordinator (REST) API calls for this call. */ - private val apiClient by lazy { CallApiClient(this) } + private val apiClient = CallApiClient( + type = type, + id = id, + state = state, + clientImpl = clientImpl, + scope = scope, + callSessionId = { sessionId }, + ringRegistrar = object : RingingCallRegistrar { + override fun beforeOutgoingStateUpdate() { + client.state._ringingCall.value = this@Call + } + + override fun afterOutgoingStateUpdate() { + client.state.addRingingCall(this@Call, RingingState.Outgoing()) + } + + override fun onAccepted() { + clientImpl.state.transitionToAcceptCall(this@Call) + } + }, + ) /** Delegate that periodically collects and reports WebRTC stats. */ - private val statsReporter by lazy { CallStatsReporter(this) } + private val statsReporter = CallStatsReporter(type, id, scope, session, state) /** Delegate that binds video tracks to renderers and handles media-quality overrides. */ - private val callRenderer by lazy { CallRenderer(this) } + private val callRenderer = CallRenderer( + type = type, + id = id, + scope = scope, + session = session, + callAnalytics = callAnalytics, + eglBase = { eglBase }, + callSessionId = { sessionId }, + ) /** Delegate that owns the peer-connection factory, media manager and audio pipeline. */ - private val media = CallMediaManager(this) + private val media = CallMediaManager( + type = type, + id = id, + clientImpl = clientImpl, + scope = scope, + state = state, + session = session, + eglBase = { eglBase }, + callProvider = { this }, + ) /** * Checks if the audioBitrateProfile has changed since the factory was created, @@ -285,18 +329,6 @@ public class Call( */ internal fun ensureFactoryMatchesAudioProfile() = media.ensureFactoryMatchesAudioProfile() - /** - * Recreates peerConnectionFactory, audioSource, audioTrack, videoSource and videoTrack - * with the current audioBitrateProfile. This should only be called before the call is joined. - */ - internal fun recreateFactoryAndAudioTracks() = media.recreateFactoryAndAudioTracks() - - /** - * Recreates peerConnectionFactory with the current audioBitrateProfile. - * This should only be called before the call is joined. - */ - internal fun recreatePeerConnectionFactory() = media.recreatePeerConnectionFactory() - internal val clientCapabilities = ConcurrentHashMap().apply { put( ClientCapability.CLIENT_CAPABILITY_SUBSCRIBER_VIDEO_PAUSE.name, @@ -307,21 +339,34 @@ public class Call( internal val mediaManager get() = media.mediaManager /** Delegate that reacts to device connectivity changes (reconnect / leave-on-timeout). */ - private val connectivityMonitor = CallConnectivityMonitor(this) + private val connectivityMonitor = CallConnectivityMonitor( + type = type, + id = id, + clientImpl = clientImpl, + scope = scope, + state = state, + reconnector = reconnector, + lifecycle = lifecycle, + reconnectDeadlineMillis = { reconnectDeadlineMillis }, + ) /** Delegate that drives the join flow (permissions, retry loop, session creation). */ private val joinCoordinator = CallJoinCoordinator(this) internal var reconnectDeadlineMillis: Int = 10_000 - /** Delegate that owns the unified reconnect state machine (fast / rejoin / migrate). */ - private val reconnector = CallReconnector(this) + private var sfuListener: Job? = null + private var sfuEvents: Job? = null - /** Delegate that owns leave / end / cleanup teardown and the destroyed flag. */ - private val lifecycle = CallLifecycleManager(this) + /** Delegate that restarts ICE when the publisher/subscriber connections drop. */ + private val iceMonitor = CallIceConnectionMonitor(type, id, scope, session) - /** Returns whether the device currently has network connectivity. */ - internal fun isNetworkConnected(): Boolean = connectivityMonitor.isConnected() + init { + media.startAudioLevelMonitoring() + powerManager = safeCallWithDefault(null) { + clientImpl.context.getSystemService(POWER_SERVICE) as? PowerManager + } + } /** Stops the ICE and connectivity monitors (used during teardown). */ internal fun stopConnectionMonitors() { @@ -335,19 +380,6 @@ public class Call( statsReporter.stop() } - private var sfuListener: Job? = null - private var sfuEvents: Job? = null - - /** Delegate that restarts ICE when the publisher/subscriber connections drop. */ - private val iceMonitor = CallIceConnectionMonitor(this) - - init { - media.startAudioLevelMonitoring() - powerManager = safeCallWithDefault(null) { - clientImpl.context.getSystemService(POWER_SERVICE) as? PowerManager - } - } - /** Basic crud operations */ suspend fun get(): Result = apiClient.get() diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index 2500481a87e..0e154a90299 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt @@ -1515,7 +1515,8 @@ class MediaManagerImpl( * * Once released, [runOnAudioTrackIfAvailable] / [runOnVideoTrackIfAvailable] become no-ops. Set * only on the terminal [cleanup] path and NOT in [disposeTracksAndSources], because - * [Call.recreateFactoryAndAudioTracks] disposes and then deliberately recreates before joining. + * CallMediaManager.recreateFactoryAndAudioTracks disposes and then deliberately recreates + * before joining. */ private var released = false diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt index 80c73cc4717..501a8f3c868 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt @@ -46,8 +46,8 @@ import io.getstream.android.video.generated.models.UpdateCallResponse import io.getstream.android.video.generated.models.UpdateUserPermissionsResponse import io.getstream.log.taggedLogger import io.getstream.result.Result -import io.getstream.video.android.core.Call -import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.model.MuteUsersData import io.getstream.video.android.core.model.QueriedMembers import io.getstream.video.android.core.model.RejectReason @@ -55,25 +55,43 @@ import io.getstream.video.android.core.model.SortField import io.getstream.video.android.core.model.UpdateUserPermissionsData import io.getstream.video.android.core.recording.RecordingType import io.getstream.video.android.core.utils.toQueriedMembers +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.threeten.bp.OffsetDateTime /** - * Wraps all coordinator (REST) API calls for a [Call]. Each method delegates to the - * coordinator client and, where relevant, updates [Call.state] from the response. + * Reflects this call's ringing-lifecycle transitions into the shared client state. + * + * These are identity hand-offs (the owning call registers itself with client state), so + * they're provided by the call rather than reached into from this component. + * + * Outgoing ringing is split into two steps because [CallState.updateFromResponse] reads + * the current ringingCall: the call must be set as the ringing call *before* the state + * update (which reads it) and added to the ringing-call registry *after* it. + */ +internal interface RingingCallRegistrar { + fun beforeOutgoingStateUpdate() + fun afterOutgoingStateUpdate() + fun onAccepted() +} + +/** + * Wraps all coordinator (REST) API calls for a call. Each method delegates to the + * coordinator client and, where relevant, updates [CallState] from the response. * * This component holds no mutable call state — it is a stateless façade over the - * coordinator endpoints, extracted from [Call] to keep the public class focused. + * coordinator endpoints, extracted from the call to keep the public class focused. */ internal class CallApiClient( - private val call: Call, + private val type: String, + private val id: String, + private val state: CallState, + private val clientImpl: StreamVideoClient, + private val scope: CoroutineScope, + private val callSessionId: () -> String, + private val ringRegistrar: RingingCallRegistrar, ) { - private val logger by taggedLogger("Call:ApiClient:${call.type}:${call.id}") - - private val clientImpl get() = call.clientImpl - private val type get() = call.type - private val id get() = call.id - private val state get() = call.state + private val logger by taggedLogger("Call:ApiClient:$type:$id") suspend fun get(): Result { val response = clientImpl.getCall(type, id) @@ -123,16 +141,14 @@ internal class CallApiClient( } response.onSuccess { - /** - * Because [io.getstream.video.android.core.CallState.updateFromResponse] reads the - * value of [io.getstream.video.android.core.ClientState.ringingCall] - */ + // Ordering matters: the ringing call must be registered before the state + // update (which reads ringingCall) and added after. See OutgoingRingRegistrar. if (ring) { - call.client.state._ringingCall.value = call + ringRegistrar.beforeOutgoingStateUpdate() } state.updateFromResponse(it) if (ring) { - call.client.state.addRingingCall(call, RingingState.Outgoing()) + ringRegistrar.afterOutgoingStateUpdate() } } return response @@ -354,7 +370,7 @@ internal class CallApiClient( logger.d { "[accept] #ringing; no args, call_id:$id" } state.acceptedOnThisDevice = true - clientImpl.state.transitionToAcceptCall(call) + ringRegistrar.onAccepted() return clientImpl.accept(type, id) } @@ -368,11 +384,11 @@ internal class CallApiClient( reason: String? = null, custom: Map? = null, ) { - call.scope.launch { + scope.launch { clientImpl.collectFeedback( callType = type, id = id, - sessionId = call.sessionId, + sessionId = callSessionId(), rating = rating, reason = reason, custom = custom, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt index 14b8dc5ee69..9d78ffe462c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt @@ -18,28 +18,38 @@ package io.getstream.video.android.core.call.components import io.getstream.log.taggedLogger import io.getstream.video.android.core.BackendCause -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.CallState import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.internal.network.NetworkStateProvider +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import stream.video.sfu.models.WebsocketReconnectStrategy /** - * Observes device network connectivity for a [Call] and drives the call's response: + * Observes device network connectivity for a call and drives the call's response: * triggering a fast/rejoin reconnect when connectivity returns, and leaving the call if * the device stays offline past the configured `leaveAfterDisconnectSeconds`. * * Also owns the call's subscription to the underlying [NetworkStateProvider]. + * + * @param reconnectDeadlineMillis a provider for the (mutable, runtime-updated) fast-reconnect + * deadline; read on each connectivity change rather than captured as a snapshot. */ internal class CallConnectivityMonitor( - private val call: Call, + private val type: String, + private val id: String, + private val clientImpl: StreamVideoClient, + private val scope: CoroutineScope, + private val state: CallState, + private val reconnector: CallReconnector, + private val lifecycle: CallLifecycleManager, + private val reconnectDeadlineMillis: () -> Int, ) { - private val logger by taggedLogger("Call:ConnectivityMonitor:${call.type}:${call.id}") - - private val clientImpl get() = call.clientImpl + private val logger by taggedLogger("Call:ConnectivityMonitor:$type:$id") private val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } @@ -51,15 +61,16 @@ internal class CallConnectivityMonitor( leaveTimeoutAfterDisconnect?.cancel() val elapsedTimeMils = System.currentTimeMillis() - lastDisconnect + val deadlineMillis = reconnectDeadlineMillis() logger.d { - "[NetworkStateListener#onConnected] #network; no args, elapsedTimeMils:$elapsedTimeMils, lastDisconnect:$lastDisconnect, reconnectDeadlineMils:${call.reconnectDeadlineMillis}" + "[NetworkStateListener#onConnected] #network; no args, elapsedTimeMils:$elapsedTimeMils, lastDisconnect:$lastDisconnect, reconnectDeadlineMils:$deadlineMillis" } - val strategy = if (lastDisconnect > 0 && elapsedTimeMils < call.reconnectDeadlineMillis) { + val strategy = if (lastDisconnect > 0 && elapsedTimeMils < deadlineMillis) { WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST } else { WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN } - call.reconnect(strategy, "NetworkStateListener#onConnected") + reconnector.reconnect(strategy, "NetworkStateListener#onConnected") } override suspend fun onDisconnected() { @@ -70,9 +81,9 @@ internal class CallConnectivityMonitor( logger.d { "[NetworkStateListener#onDisconnected] #network; new lastDisconnect:$lastDisconnect" } - leaveTimeoutAfterDisconnect = call.scope.launch { + leaveTimeoutAfterDisconnect = scope.launch { delay(clientImpl.leaveAfterDisconnectSeconds * 1000) - val conn = call.state.connection.value + val conn = state.connection.value if (conn is RealtimeConnection.Connected) { logger.d { "[NetworkStateListener#onDisconnected] #network; Already reconnected ($conn) — not leaving" @@ -83,7 +94,7 @@ internal class CallConnectivityMonitor( logger.d { "[NetworkStateListener#onDisconnected] #network; Leaving after being disconnected for ${clientImpl.leaveAfterDisconnectSeconds} (connection=$conn)" } - call.leave( + lifecycle.leave( CallLeaveReason.Backend( cause = BackendCause.LEAVE_TIMEOUT_AFTER_DISCONNECT, message = message, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt index ab7ef57c4f6..5c3732d93ce 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt @@ -18,22 +18,25 @@ package io.getstream.video.android.core.call.components import io.getstream.android.video.generated.models.VideoEvent import io.getstream.log.taggedLogger -import io.getstream.video.android.core.Call import io.getstream.video.android.core.EventSubscription import io.getstream.video.android.core.events.GoAwayEvent import io.getstream.video.android.core.events.VideoEventListener +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch import java.util.Collections /** - * Owns the event pipeline for a [Call]: the shared [events] flow, the set of legacy + * Owns the event pipeline for a call: the shared [events] flow, the set of legacy * [EventSubscription]s, and the dispatch / handling of incoming [VideoEvent]s. */ internal class CallEventManager( - private val call: Call, + private val type: String, + private val id: String, + private val scope: CoroutineScope, + private val reconnector: CallReconnector, ) { - private val logger by taggedLogger("Call:EventManager:${call.type}:${call.id}") + private val logger by taggedLogger("Call:EventManager:$type:$id") val events = MutableSharedFlow(extraBufferCapacity = 150) @@ -68,8 +71,8 @@ internal class CallEventManager( when (event) { is GoAwayEvent -> - call.scope.launch { - call.migrate() + scope.launch { + reconnector.migrate() } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt index 038633843e3..4bdcdea0767 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt @@ -17,8 +17,10 @@ package io.getstream.video.android.core.call.components import io.getstream.log.taggedLogger -import io.getstream.video.android.core.Call +import io.getstream.video.android.core.call.RtcSession +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -26,13 +28,16 @@ import kotlinx.coroutines.launch import org.webrtc.PeerConnection /** - * Watches the publisher and subscriber peer-connection ICE states for a [Call] and + * Watches the publisher and subscriber peer-connection ICE states for a call and * triggers an ICE restart whenever a connection FAILS or becomes DISCONNECTED. */ internal class CallIceConnectionMonitor( - private val call: Call, + private val type: String, + private val id: String, + private val scope: CoroutineScope, + private val session: MutableStateFlow, ) { - private val logger by taggedLogger("Call:IceMonitor:${call.type}:${call.id}") + private val logger by taggedLogger("Call:IceMonitor:$type:$id") private var monitorPublisherPCStateJob: Job? = null private var monitorSubscriberPCStateJob: Job? = null @@ -44,8 +49,8 @@ internal class CallIceConnectionMonitor( private fun startPublisherMonitor() { monitorPublisherPCStateJob?.cancel() - monitorPublisherPCStateJob = call.scope.launch { - call.session + monitorPublisherPCStateJob = scope.launch { + session .filterNotNull() .flatMapLatest { it.publisher.filterNotNull() } .flatMapLatest { publisher -> @@ -68,11 +73,11 @@ internal class CallIceConnectionMonitor( private fun startSubscriberMonitor() { monitorSubscriberPCStateJob?.cancel() - monitorSubscriberPCStateJob = call.scope.launch { - call.session.value?.subscriber?.value?.iceState?.collect { + monitorSubscriberPCStateJob = scope.launch { + session.value?.subscriber?.value?.iceState?.collect { when (it) { PeerConnection.IceConnectionState.FAILED, PeerConnection.IceConnectionState.DISCONNECTED -> { - call.session.value?.requestSubscriberIceRestart() + session.value?.requestSubscriberIceRestart() } else -> { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt index 4173084d286..9366889bbf7 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -23,34 +23,50 @@ import io.getstream.android.video.generated.models.OwnCapability import io.getstream.android.video.generated.models.VideoSettingsResponse import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState import io.getstream.video.android.core.CameraDirection import io.getstream.video.android.core.DeviceStatus import io.getstream.video.android.core.MediaManagerImpl +import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.getstream.video.android.core.call.utils.SoundInputProcessor import io.getstream.video.android.core.utils.RampValueUpAndDownHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import org.webrtc.EglBase import org.webrtc.audio.JavaAudioDeviceModule.AudioSamples /** - * Owns the media pipeline for a [Call]: the [StreamPeerConnectionFactory] lifecycle, the + * Owns the media pipeline for a call: the [StreamPeerConnectionFactory] lifecycle, the * [MediaManagerImpl] (camera / microphone / speaker / screen share), audio-level monitoring, * settings-driven device initialisation and screen sharing. + * + * @param eglBase provider for the shared EGL context; invoked lazily so the native context + * isn't created until the media manager / factory actually needs it. + * @param callProvider supplies the owning call, needed only to build [MediaManagerImpl] (a + * public type that takes a [Call]); invoked lazily when the media manager is created. */ internal class CallMediaManager( - private val call: Call, + private val type: String, + private val id: String, + private val clientImpl: StreamVideoClient, + private val scope: CoroutineScope, + private val state: CallState, + private val session: MutableStateFlow, + private val eglBase: () -> EglBase, + private val callProvider: () -> Call, ) { - private val logger by taggedLogger("Call:MediaManager:${call.type}:${call.id}") - - private val clientImpl get() = call.clientImpl + private val logger by taggedLogger("Call:MediaManager:$type:$id") private val soundInputProcessor = SoundInputProcessor(thresholdCrossedCallback = { if (!mediaManager.microphone.isEnabled.value) { - call.state.markSpeakingAsMuted() + state.markSpeakingAsMuted() } }) private val audioLevelOutputHelper = RampValueUpAndDownHelper() @@ -67,10 +83,10 @@ internal class CallMediaManager( _peerConnectionFactory = StreamPeerConnectionFactory( context = clientImpl.context, audioProcessing = clientImpl.audioProcessing, - audioUsage = clientImpl.callServiceConfigRegistry.get(call.type).audioUsage, - audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(call.type).audioUsage }, + audioUsage = clientImpl.callServiceConfigRegistry.get(type).audioUsage, + audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, audioBitrateProfileProvider = { mediaManager.microphone.audioBitrateProfile.value }, - sharedEglBaseProvider = { call.eglBase }, + sharedEglBaseProvider = { eglBase() }, webRtcLoggingLevel = clientImpl.loggingLevel.webRtcLoggingLevel, ) } @@ -86,17 +102,17 @@ internal class CallMediaManager( } else { MediaManagerImpl( clientImpl.context, - call, - call.scope, - call.eglBase.eglBaseContext, - clientImpl.callServiceConfigRegistry.get(call.type).audioUsage, - ) { clientImpl.callServiceConfigRegistry.get(call.type).audioUsage } + callProvider(), + scope, + eglBase().eglBaseContext, + clientImpl.callServiceConfigRegistry.get(type).audioUsage, + ) { clientImpl.callServiceConfigRegistry.get(type).audioUsage } } } /** Starts streaming smoothed microphone audio levels into [localMicrophoneAudioLevel]. */ fun startAudioLevelMonitoring() { - call.scope.launch { + scope.launch { soundInputProcessor.currentAudioLevel.collect { audioLevelOutputHelper.rampToValue(it) } @@ -233,15 +249,15 @@ internal class CallMediaManager( microphone.select(deviceBeforeHeadset) } } - }.launchIn(call.scope) + }.launchIn(scope) } fun startScreenSharing( mediaProjectionPermissionResultData: Intent, includeAudio: Boolean = false, ) { - if (call.state.ownCapabilities.value.contains(OwnCapability.Screenshare)) { - call.session.value?.setScreenShareTrack() + if (state.ownCapabilities.value.contains(OwnCapability.Screenshare)) { + session.value?.setScreenShareTrack() mediaManager.screenShare.enable( mediaProjectionPermissionResultData, includeAudio = includeAudio, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt index 4e1eaf373f4..6dfb29defab 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt @@ -73,6 +73,11 @@ internal class CallReconnector( private val session get() = call.session private val callAnalytics get() = call.callAnalytics + // Read connectivity from the leaf NetworkStateProvider directly rather than routing + // through CallConnectivityMonitor — that back-reference would form a dependency cycle + // (ConnectivityMonitor → Reconnector → ConnectivityMonitor). + private val network get() = clientImpl.coordinatorConnectionModule.networkStateProvider + private val reconnectMutex = Mutex() /** @@ -178,7 +183,7 @@ internal class CallReconnector( // Wait for network before doing anything else. Polls without // consuming the attempt budget — the elapsed-time guard below // will still fire if we wait too long. - if (!call.isNetworkConnected()) { + if (!network.isConnected()) { logger.d { "[reconnect] Network unavailable — waiting for connectivity (loopIteration=$loopIteration)" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt index 63886d59e9d..693b0c53a2b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt @@ -18,15 +18,19 @@ package io.getstream.video.android.core.call.components import android.graphics.Bitmap import io.getstream.log.taggedLogger -import io.getstream.video.android.core.Call +import io.getstream.video.android.core.analytics.call.CallAnalytics +import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.connection.Subscriber import io.getstream.video.android.core.call.video.YuvFrame import io.getstream.video.android.core.model.AudioTrack import io.getstream.video.android.core.model.PreferredVideoResolution import io.getstream.video.android.core.model.VideoTrack import io.getstream.webrtc.android.ui.VideoTextureViewRenderer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine +import org.webrtc.EglBase import org.webrtc.RendererCommon import org.webrtc.VideoSink import stream.video.sfu.models.TrackType @@ -35,14 +39,22 @@ import kotlin.coroutines.resume /** * Handles binding video tracks to renderers, visibility / track-dimension updates, - * screenshots and incoming media-quality overrides for a [Call]. + * screenshots and incoming media-quality overrides for a call. + * + * @param eglBase provider for the shared EGL context; invoked lazily on first render so the + * native context isn't created until it's actually needed. + * @param callSessionId provider for the call's (mutable, runtime-updated) session id. */ internal class CallRenderer( - private val call: Call, + private val type: String, + private val id: String, + private val scope: CoroutineScope, + private val session: MutableStateFlow, + private val callAnalytics: CallAnalytics, + private val eglBase: () -> EglBase, + private val callSessionId: () -> String, ) { - private val logger by taggedLogger("Call:Renderer:${call.type}:${call.id}") - - private val session get() = call.session + private val logger by taggedLogger("Call:Renderer:$type:$id") fun setVisibility( sessionId: String, @@ -93,7 +105,7 @@ internal class CallRenderer( // Note this comes from the shared eglBase videoRenderer.init( - call.eglBase.eglBaseContext, + eglBase().eglBaseContext, object : RendererCommon.RendererEvents { override fun onFirstFrameRendered() { val width = videoRenderer.measuredWidth @@ -113,14 +125,14 @@ internal class CallRenderer( ) } onRendered(videoRenderer) - call.callAnalytics.videoAnalytics.firstVideoFrameRendered( + callAnalytics.videoAnalytics.firstVideoFrameRendered( trackType, width, height, rtcSession = session.value, sessionId, - call.sessionId, + callSessionId(), ) } @@ -168,7 +180,7 @@ internal class CallRenderer( // This has to be launched asynchronously - removing the sink on the // same thread as the videoframe is delivered will lead to a deadlock // (needs investigation why) - call.scope.launch { + scope.launch { track.video.removeSink(screenshotSink) } continuation.resume(bitmap) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt index 1d043d9dce5..4b4a50704af 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt @@ -29,9 +29,7 @@ import java.util.UUID * Keeping this state in a single component gives the join, reconnect, connectivity and * lifecycle collaborators a single source of truth to depend on. */ -internal class CallSessionManager( - @Suppress("unused") private val call: Call, -) { +internal class CallSessionManager() { /** Session handles all real time communication for video and audio. */ val session: MutableStateFlow = MutableStateFlow(null) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt index d4e4c921fdd..b134667004f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt @@ -17,8 +17,10 @@ package io.getstream.video.android.core.call.components import io.getstream.log.taggedLogger -import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState import io.getstream.video.android.core.CallStatsReport +import io.getstream.video.android.core.call.RtcSession +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -26,13 +28,17 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch /** - * Periodically collects WebRTC stats for a [Call], reports them to the SFU, and exposes + * Periodically collects WebRTC stats for a call, reports them to the SFU, and exposes * the latest report and latency history as observable flows. */ internal class CallStatsReporter( - private val call: Call, + private val type: String, + private val id: String, + private val scope: CoroutineScope, + private val session: MutableStateFlow, + private val state: CallState, ) { - private val logger by taggedLogger("Call:StatsReporter:${call.type}:${call.id}") + private val logger by taggedLogger("Call:StatsReporter:$type:$id") /** Contains stats events for observation. */ val statsReport: MutableStateFlow = MutableStateFlow(null) @@ -44,13 +50,13 @@ internal class CallStatsReporter( fun start(reportingIntervalMs: Long = 10_000) { callStatsReportingJob?.cancel() - callStatsReportingJob = call.scope.launch { + callStatsReportingJob = scope.launch { // Wait a bit before we start capturing stats delay(reportingIntervalMs) while (isActive) { delay(reportingIntervalMs) - call.session.value?.sendCallStats( + session.value?.sendCallStats( report = collectStats(), ) } @@ -62,10 +68,9 @@ internal class CallStatsReporter( } suspend fun collectStats(): CallStatsReport { - val session = call.session.value - val state = call.state - val publisherStats = runCatching { session?.getPublisherStats() }.getOrNull() - val subscriberStats = runCatching { session?.getSubscriberStats() }.getOrNull() + val currentSession = session.value + val publisherStats = runCatching { currentSession?.getPublisherStats() }.getOrNull() + val subscriberStats = runCatching { currentSession?.getSubscriberStats() }.getOrNull() runCatching { state.stats.updateFromRTCStats(publisherStats, isPublisher = true) state.stats.updateFromRTCStats(subscriberStats, isPublisher = false) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt index 090dce2864e..a06a4f88506 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt @@ -26,7 +26,6 @@ import io.getstream.android.video.generated.models.StartHLSBroadcastingResponse import io.getstream.android.video.generated.models.StopLiveResponse import io.getstream.android.video.generated.models.UpdateCallResponse import io.getstream.result.Result -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallState import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.model.RejectReason @@ -34,7 +33,6 @@ import io.getstream.video.android.core.model.SortField import io.getstream.video.android.core.recording.RecordingType import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.test.StandardTestDispatcher @@ -56,21 +54,14 @@ class CallApiClientTest { private lateinit var clientImpl: StreamVideoClient private lateinit var state: CallState - private lateinit var call: Call + private lateinit var ringRegistrar: RingingCallRegistrar private lateinit var apiClient: CallApiClient @Before fun setup() { clientImpl = mockk(relaxed = true) state = mockk(relaxed = true) - call = mockk(relaxed = true) - - every { call.type } returns "default" - every { call.id } returns "call-id" - every { call.clientImpl } returns clientImpl - every { call.state } returns state - every { call.scope } returns testScope - every { call.sessionId } returns "session-id" + ringRegistrar = mockk(relaxed = true) // Response-processing endpoints return Success so the onSuccess/state-update // branches are exercised. @@ -96,7 +87,15 @@ class CallApiClientTest { clientImpl.startBroadcasting(any(), any()) } returns Result.Success(mockk(relaxed = true)) - apiClient = CallApiClient(call) + apiClient = CallApiClient( + type = "default", + id = "call-id", + state = state, + clientImpl = clientImpl, + scope = testScope, + callSessionId = { "session-id" }, + ringRegistrar = ringRegistrar, + ) } @Test @@ -131,7 +130,8 @@ class CallApiClientTest { fun `create with ring registers an outgoing ringing call`() = runTest(testDispatcher) { apiClient.create(ring = true) - coVerify { call.client.state.addRingingCall(any(), any()) } + verify { ringRegistrar.beforeOutgoingStateUpdate() } + verify { ringRegistrar.afterOutgoingStateUpdate() } } @Test @@ -282,13 +282,14 @@ class CallApiClientTest { coVerify { clientImpl.getOrCreateCallFullMembers(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } - coVerify { call.client.state.addRingingCall(any(), any()) } + verify { ringRegistrar.afterOutgoingStateUpdate() } } @Test fun `accept marks the call accepted on this device`() = runTest(testDispatcher) { apiClient.accept() verify { state.acceptedOnThisDevice = true } + verify { ringRegistrar.onAccepted() } } @Test diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.kt index 064cb53e564..4fd4f43ba7e 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.kt @@ -17,7 +17,6 @@ package io.getstream.video.android.core.call.components import com.google.common.truth.Truth.assertThat -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallLeaveReason import io.getstream.video.android.core.CallState import io.getstream.video.android.core.RealtimeConnection @@ -39,8 +38,10 @@ import org.junit.Test import stream.video.sfu.models.WebsocketReconnectStrategy /** - * Tests [CallConnectivityMonitor]: network subscription forwarding, the reconnect - * strategy chosen on reconnection, and the delayed leave when the device stays offline. + * Tests [CallConnectivityMonitor]: network subscription forwarding, and the actions it + * drives directly on its sibling components — a fast/rejoin reconnect via [CallReconnector] + * when connectivity returns, and a leave via [CallLifecycleManager] when the device stays + * offline past the deadline. */ class CallConnectivityMonitorTest { @@ -51,15 +52,17 @@ class CallConnectivityMonitorTest { private lateinit var clientImpl: StreamVideoClient private lateinit var state: CallState private lateinit var connectionFlow: MutableStateFlow - private lateinit var call: Call + private lateinit var reconnector: CallReconnector + private lateinit var lifecycle: CallLifecycleManager @Before fun setup() { network = mockk(relaxed = true) clientImpl = mockk(relaxed = true) state = mockk(relaxed = true) + reconnector = mockk(relaxed = true) + lifecycle = mockk(relaxed = true) connectionFlow = MutableStateFlow(RealtimeConnection.Reconnecting) - call = mockk(relaxed = true) val coordinatorModule = mockk(relaxed = true) every { coordinatorModule.networkStateProvider } returns network @@ -67,16 +70,19 @@ class CallConnectivityMonitorTest { every { clientImpl.leaveAfterDisconnectSeconds } returns 1L every { network.isConnected() } returns true - every { call.type } returns "default" - every { call.id } returns "call-id" - every { call.clientImpl } returns clientImpl - every { call.scope } returns testScope - every { call.state } returns state - every { call.reconnectDeadlineMillis } returns 10_000 every { state.connection } returns connectionFlow } - private fun monitor() = CallConnectivityMonitor(call) + private fun monitor() = CallConnectivityMonitor( + type = "default", + id = "call-id", + clientImpl = clientImpl, + scope = testScope, + state = state, + reconnector = reconnector, + lifecycle = lifecycle, + reconnectDeadlineMillis = { 10_000 }, + ) private fun listenerOf(monitor: CallConnectivityMonitor): NetworkStateProvider.NetworkStateListener { val field = CallConnectivityMonitor::class.java.getDeclaredField("listener") @@ -98,19 +104,26 @@ class CallConnectivityMonitorTest { } @Test - fun `first connection with no prior disconnect rejoins`() = runTest(testDispatcher) { + fun `first connection with no prior disconnect triggers a rejoin`() = runTest( + testDispatcher, + ) { val listener = listenerOf(monitor()) listener.onConnected() advanceUntilIdle() coVerify { - call.reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, any()) + reconnector.reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + any(), + ) } } @Test - fun `reconnection soon after a disconnect uses fast reconnect`() = runTest(testDispatcher) { + fun `reconnection soon after a disconnect triggers a fast reconnect`() = runTest( + testDispatcher, + ) { val listener = listenerOf(monitor()) listener.onDisconnected() @@ -118,7 +131,10 @@ class CallConnectivityMonitorTest { advanceUntilIdle() coVerify { - call.reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, any()) + reconnector.reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + any(), + ) } } @@ -130,7 +146,7 @@ class CallConnectivityMonitorTest { advanceTimeBy(2_000) advanceUntilIdle() - verify { call.leave(any()) } + verify { lifecycle.leave(any()) } } @Test @@ -142,6 +158,6 @@ class CallConnectivityMonitorTest { advanceTimeBy(2_000) advanceUntilIdle() - verify(exactly = 0) { call.leave(any()) } + verify(exactly = 0) { lifecycle.leave(any()) } } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt index ba77a461bc1..830f507e063 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt @@ -18,13 +18,11 @@ package io.getstream.video.android.core.call.components import com.google.common.truth.Truth.assertThat import io.getstream.android.video.generated.models.VideoEvent -import io.getstream.video.android.core.Call import io.getstream.video.android.core.base.DispatcherRule import io.getstream.video.android.core.events.GoAwayEvent import io.getstream.video.android.core.events.SFUConnectedEvent import io.getstream.video.android.core.events.VideoEventListener import io.mockk.coVerify -import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher @@ -36,7 +34,7 @@ import org.junit.Rule import org.junit.Test /** - * Unit tests for [CallEventManager] which owns the event pipeline for a [Call]: + * Unit tests for [CallEventManager] which owns the event pipeline for a call: * the shared events flow, legacy subscriptions and dispatch of incoming events. */ class CallEventManagerTest { @@ -47,13 +45,9 @@ class CallEventManagerTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) - private val call = mockk(relaxed = true).also { - every { it.type } returns "default" - every { it.id } returns "call-id" - every { it.scope } returns testScope - } + private val reconnector = mockk(relaxed = true) - private fun manager() = CallEventManager(call) + private fun manager() = CallEventManager("default", "call-id", testScope, reconnector) @Test fun `subscribe without filter receives every fired event`() { @@ -125,13 +119,13 @@ class CallEventManagerTest { } @Test - fun `handleEvent triggers migrate on GoAwayEvent`() = runTest(testDispatcher) { + fun `handleEvent triggers a migrate on GoAwayEvent`() = runTest(testDispatcher) { val manager = manager() manager.handleEvent(mockk(relaxed = true)) advanceUntilIdle() - coVerify(exactly = 1) { call.migrate() } + coVerify(exactly = 1) { reconnector.migrate() } } @Test @@ -141,6 +135,6 @@ class CallEventManagerTest { manager.handleEvent(mockk(relaxed = true)) advanceUntilIdle() - coVerify(exactly = 0) { call.migrate() } + coVerify(exactly = 0) { reconnector.migrate() } } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt index c5d2828646a..c8f8736af56 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt @@ -16,7 +16,6 @@ package io.getstream.video.android.core.call.components -import io.getstream.video.android.core.Call 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 @@ -45,7 +44,6 @@ class CallIceConnectionMonitorTest { private lateinit var publisher: Publisher private lateinit var subscriber: Subscriber private lateinit var sessionFlow: MutableStateFlow - private lateinit var call: Call @Before fun setup() { @@ -53,17 +51,12 @@ class CallIceConnectionMonitorTest { publisher = mockk(relaxed = true) subscriber = mockk(relaxed = true) sessionFlow = MutableStateFlow(session) - call = mockk(relaxed = true) - every { call.type } returns "default" - every { call.id } returns "call-id" - every { call.scope } returns testScope - every { call.session } returns sessionFlow every { session.publisher } returns MutableStateFlow(publisher) every { session.subscriber } returns MutableStateFlow(subscriber) } - private fun monitor() = CallIceConnectionMonitor(call) + private fun monitor() = CallIceConnectionMonitor("default", "call-id", testScope, sessionFlow) @Test fun `failed publisher ice state triggers an ice restart`() = runTest(testDispatcher) { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt index 9fc4a8e9d11..190427ed4bc 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt @@ -57,7 +57,6 @@ class CallMediaManagerTest { private lateinit var state: CallState private lateinit var sessionFlow: MutableStateFlow private lateinit var mediaManager: MediaManagerImpl - private lateinit var call: Call @Before fun setup() { @@ -65,15 +64,9 @@ class CallMediaManagerTest { state = mockk(relaxed = true) sessionFlow = MutableStateFlow(null) mediaManager = mockk(relaxed = true) - call = mockk(relaxed = true) - - every { call.type } returns "default" - every { call.id } returns "call-id" - every { call.clientImpl } returns clientImpl - every { call.state } returns state - every { call.scope } returns testScope - every { call.session } returns sessionFlow + // MediaManagerImpl is provided via testInstanceProvider, so eglBase / callProvider + // are never invoked and no real Call or native resources are needed. Call.testInstanceProvider.mediaManagerCreator = { mediaManager } } @@ -82,7 +75,16 @@ class CallMediaManagerTest { Call.testInstanceProvider.mediaManagerCreator = null } - private fun manager() = CallMediaManager(call) + private fun manager() = CallMediaManager( + type = "default", + id = "call-id", + clientImpl = clientImpl, + scope = testScope, + state = state, + session = sessionFlow, + eglBase = { mockk(relaxed = true) }, + callProvider = { mockk(relaxed = true) }, + ) @Test fun `startScreenSharing enables screen share when the capability is granted`() { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt index 2f58cf297f2..9ed0ca8a7fc 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt @@ -74,7 +74,9 @@ class CallReconnectorTest { every { call.state } returns state every { call.session } returns sessionFlow every { call.isDestroyed } returns false - every { call.isNetworkConnected() } returns true + every { + clientImpl.coordinatorConnectionModule.networkStateProvider.isConnected() + } returns true every { call.reconnectDeadlineMillis } returns 60_000 every { call.location } returns null every { state.connection } returns connectionFlow diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt index 4faa1689964..e2eb7ca6f67 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt @@ -16,7 +16,6 @@ package io.getstream.video.android.core.call.components -import io.getstream.video.android.core.Call import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.connection.Subscriber import io.getstream.video.android.core.model.AudioTrack @@ -37,13 +36,16 @@ import java.util.concurrent.ConcurrentHashMap class CallRendererTest { private val sessionFlow = MutableStateFlow(null) - private val call = mockk(relaxed = true).also { - every { it.type } returns "default" - every { it.id } returns "call-id" - every { it.session } returns sessionFlow - } - private fun renderer() = CallRenderer(call) + private fun renderer() = CallRenderer( + type = "default", + id = "call-id", + scope = mockk(relaxed = true), + session = sessionFlow, + callAnalytics = mockk(relaxed = true), + eglBase = { mockk(relaxed = true) }, + callSessionId = { "call-id" }, + ) @Test fun `setVisibility updates track dimensions with default dimension`() { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt index 1885921de90..e4a0bfa2e73 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt @@ -17,7 +17,6 @@ package io.getstream.video.android.core.call.components import com.google.common.truth.Truth.assertThat -import io.getstream.video.android.core.Call import io.getstream.video.android.core.call.RtcSession import io.mockk.mockk import org.junit.Test @@ -28,9 +27,7 @@ import org.junit.Test */ class CallSessionManagerTest { - private val call = mockk(relaxed = true) - - private fun manager() = CallSessionManager(call) + private fun manager() = CallSessionManager() @Test fun `session starts empty and can be replaced`() { From 1d4cdf8de45124c61c3d6bdaa5a0a7bcf590327d Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Mon, 3 Aug 2026 15:47:00 +0530 Subject: [PATCH 07/10] refactor(core): finish decoupling Call collaborators and repair the suite Removes the last Call references from the extracted components so no class under call/components holds the facade any more. CallLifecycleManager now takes its collaborators directly (lazy providers, since it is constructed before CallState and the monitors exist), which made Call.stopConnectionMonitors/stopStatsReporting/cancelSfuObservers/ shutDownJobsGracefully dead; they are removed. CallMediaManager gains disableLocalCapture() so the lifecycle no longer reaches through to the device handles. The three callback interfaces into Call (CallHost, CallTeardownHost, RingingCallRegistrar) were named after who implements them rather than what they do, and six of their eight methods did the same thing: register or deregister this call in the client's ringing/active registries. Two were byte-identical. They collapse into one ClientCallRegistry; the genuine outliers (hasRequiredPermissions, shutDownJobs) become plain lambdas. Two production fixes surfaced while repairing the tests: - CallMediaManager evaluated eglBase().eglBaseContext to build an argument for MediaManagerFactory.create, forcing a real EGL context before the factory ran. Call owns both the context and the factory, so the parameter is dropped and the factory resolves it itself. - The reconnect loop reads connectivity straight off the connection module (it must not go through CallConnectivityMonitor, which would close a dependency cycle), but injectMockNetwork was repointed at the monitor. The loop therefore polled the real provider and stalled without consuming an attempt. Injecting at the module fixes three reconnect tests that had been failing since the decomposition commit. JoinRecoverableFailureTest is rebuilt on the coordinator harness: it relied on spying Call and reflectively repointing CallJoinCoordinator.call, a field that no longer exists. Core suite: 978 tests, 0 failures. Co-authored-by: Cursor --- .../io/getstream/video/android/core/Call.kt | 250 ++++++++++++------ .../core/call/components/CallApiClient.kt | 28 +- .../components/CallIceConnectionMonitor.kt | 10 +- .../call/components/CallJoinCoordinator.kt | 115 ++++---- .../call/components/CallLifecycleManager.kt | 99 ++++--- .../core/call/components/CallMediaManager.kt | 35 ++- .../core/call/components/CallReconnector.kt | 162 ++++++------ .../core/call/components/CallRenderer.kt | 23 +- .../call/components/CallSessionManager.kt | 28 +- .../core/call/components/CallStatsReporter.kt | 7 +- .../call/components/ClientCallRegistry.kt | 47 ++++ .../call/components/MediaManagerFactory.kt | 33 +++ .../core/call/components/RtcSessionFactory.kt | 39 +++ .../core/call/components/SessionMonitor.kt | 88 ++++++ .../video/android/core/CallTestSeams.kt | 65 +++++ .../core/call/components/CallApiClientTest.kt | 14 +- .../CallIceConnectionMonitorTest.kt | 8 +- .../components/CallJoinCoordinatorTest.kt | 218 ++++++++------- .../call/components/CallMediaManagerTest.kt | 23 +- .../call/components/CallReconnectorTest.kt | 83 +++--- .../core/call/components/CallRendererTest.kt | 22 +- .../call/components/CallSessionManagerTest.kt | 7 +- .../core/reconnect/FailedSfuIdsTest.kt | 16 +- .../reconnect/ReconnectAttemptsCountTest.kt | 21 +- .../core/reconnect/ReconnectSessionIdTest.kt | 16 +- .../core/rtc/JoinRecoverableFailureTest.kt | 144 +++++----- .../core/rtc/ReconnectEscalationTest.kt | 3 +- 27 files changed, 966 insertions(+), 638 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/ClientCallRegistry.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/MediaManagerFactory.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/RtcSessionFactory.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/SessionMonitor.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt 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 86b98e4c433..b1a55f2a1dd 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 @@ -67,13 +67,15 @@ import io.getstream.video.android.core.call.components.CallReconnector import io.getstream.video.android.core.call.components.CallRenderer import io.getstream.video.android.core.call.components.CallSessionManager import io.getstream.video.android.core.call.components.CallStatsReporter -import io.getstream.video.android.core.call.components.RingingCallRegistrar +import io.getstream.video.android.core.call.components.ClientCallRegistry +import io.getstream.video.android.core.call.components.MediaManagerFactory +import io.getstream.video.android.core.call.components.RtcSessionFactory +import io.getstream.video.android.core.call.components.SessionMonitor import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.getstream.video.android.core.call.scope.ScopeProvider import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.getstream.video.android.core.call.video.VideoFilter import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings -import io.getstream.video.android.core.events.JoinCallResponseEvent import io.getstream.video.android.core.events.VideoEventListener import io.getstream.video.android.core.internal.InternalStreamVideoApi import io.getstream.video.android.core.model.PreferredVideoResolution @@ -81,6 +83,7 @@ import io.getstream.video.android.core.model.QueriedMembers import io.getstream.video.android.core.model.RejectReason import io.getstream.video.android.core.model.SortField import io.getstream.video.android.core.model.VideoTrack +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController import io.getstream.video.android.core.recording.RecordingType import io.getstream.video.android.core.socket.common.scope.ClientScope import io.getstream.video.android.core.socket.common.scope.UserScope @@ -89,7 +92,6 @@ import io.getstream.video.android.core.utils.safeCallWithDefault import io.getstream.video.android.model.User import io.getstream.webrtc.android.ui.VideoTextureViewRenderer import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow @@ -143,7 +145,7 @@ public class Call( private val sessionManager = CallSessionManager() /** Session handles all real time communication for video and audio */ - internal val session: MutableStateFlow get() = sessionManager.session + internal val session: StateFlow get() = sessionManager.session var sessionId: String get() = sessionManager.sessionId @@ -182,11 +184,103 @@ public class Call( // TODO(v2): replace this with a proper dependency injection boundary. internal var unitTestRtcSessionFactory: (() -> RtcSession)? = null - /** Delegate that owns the unified reconnect state machine (fast / rejoin / migrate). */ - private val reconnector = CallReconnector(this) + /** + * Creates [RtcSession] instances for join / rejoin / migrate. Captures `this` so the + * session's Call dependency never leaks into the join/reconnect orchestrators. + */ + private val sessionFactory = RtcSessionFactory { + sessionId, sessionCounter, sfuUrl, sfuWsUrl, sfuToken, sfuName, iceServers -> + unitTestRtcSessionFactory?.invoke() ?: RtcSession( + client = clientImpl, + sessionCounter = sessionCounter, + powerManager = powerManager, + call = this, + sessionId = sessionId, + apiKey = clientImpl.apiKey, + lifecycle = clientImpl.coordinatorConnectionModule.lifecycle, + sfuUrl = sfuUrl, + sfuWsUrl = sfuWsUrl, + sfuToken = sfuToken, + sfuName = sfuName, + remoteIceServers = iceServers, + sfuAnalytics = callAnalytics.sfuAnalytics.apply { + sfuAnalyticsStateHolder.updateSfuId(sfuName) + }, + ) + } + + /** + * The call's registration in the client-level ringing / active / telecom registries. Every + * operation needs this instance, which the extracted components deliberately don't hold. + */ + private val callRegistry = object : ClientCallRegistry { + override fun markRinging() { + clientImpl.state._ringingCall.value = this@Call + } + + override fun registerOutgoingRing() { + client.state.addRingingCall(this@Call, RingingState.Outgoing()) + } + + override fun markActive() { + client.state.setActiveCall(this@Call) + } + + override fun markAccepted() { + clientImpl.state.transitionToAcceptCall(this@Call) + } + + override fun detach() { + if (id == client.state.activeCall.value?.id) { + client.state.removeActiveCall(this@Call) // Will also stop CallService + } + if (id == client.state.ringingCall.value?.id) { + client.state.removeRingingCall(this@Call) + } + TelecomCallController(client.context).leaveCall(this@Call) + clientImpl.onCallCleanUp(this@Call) + } + } /** Delegate that owns leave / end / cleanup teardown and the destroyed flag. */ - private val lifecycle = CallLifecycleManager(this) + private val lifecycle: CallLifecycleManager = CallLifecycleManager( + clientImpl = clientImpl, + sessionManager = sessionManager, + scopeProvider = scopeProvider, + callRegistry = callRegistry, + // Lets the REST calls queued before leave finish before the scope is torn down. + shutDownJobs = { + UserScope(ClientScope()).launch { + supervisorJob.children.forEach { it.join() } + supervisorJob.cancel() + } + scope.cancel() + }, + stateProvider = { state }, + callAnalyticsProvider = { callAnalytics }, + statsReporter = { statsReporter }, + media = { media }, + sessionMonitor = { sessionMonitor }, + iceMonitor = { iceMonitor }, + connectivityMonitor = { connectivityMonitor }, + type = type, + id = id, + ) + + /** Delegate that owns the unified reconnect state machine (fast / rejoin / migrate). */ + private val reconnector: CallReconnector = CallReconnector( + clientImpl = clientImpl, + sessionManager = sessionManager, + sessionFactory = sessionFactory, + lifecycle = lifecycle, + sessionMonitor = { sessionMonitor }, + stateProvider = { state }, + callAnalyticsProvider = { callAnalytics }, + statsReporter = { statsReporter }, + joinCoordinator = { joinCoordinator }, + type = type, + id = id, + ) /** Delegate that owns the event flow, subscriptions and event dispatch. */ private val eventManager = CallEventManager(type, id, scope, reconnector) @@ -279,35 +373,39 @@ public class Call( clientImpl = clientImpl, scope = scope, callSessionId = { sessionId }, - ringRegistrar = object : RingingCallRegistrar { - override fun beforeOutgoingStateUpdate() { - client.state._ringingCall.value = this@Call - } - - override fun afterOutgoingStateUpdate() { - client.state.addRingingCall(this@Call, RingingState.Outgoing()) - } - - override fun onAccepted() { - clientImpl.state.transitionToAcceptCall(this@Call) - } - }, + callRegistry = callRegistry, ) /** Delegate that periodically collects and reports WebRTC stats. */ - private val statsReporter = CallStatsReporter(type, id, scope, session, state) + private val statsReporter = CallStatsReporter(type, id, scope, sessionManager, state) /** Delegate that binds video tracks to renderers and handles media-quality overrides. */ private val callRenderer = CallRenderer( type = type, id = id, scope = scope, - session = session, + sessionManager = sessionManager, callAnalytics = callAnalytics, eglBase = { eglBase }, callSessionId = { sessionId }, ) + /** + * Creates [MediaManagerImpl] for this call. Captures `this` (and the test hook) so + * [CallMediaManager] never needs a Call reference. + */ + private val mediaManagerFactory = MediaManagerFactory { audioUsage, audioUsageProvider -> + testInstanceProvider.mediaManagerCreator?.invoke() + ?: MediaManagerImpl( + clientImpl.context, + this, + scope, + eglBase.eglBaseContext, + audioUsage, + audioUsageProvider, + ) + } + /** Delegate that owns the peer-connection factory, media manager and audio pipeline. */ private val media = CallMediaManager( type = type, @@ -315,9 +413,9 @@ public class Call( clientImpl = clientImpl, scope = scope, state = state, - session = session, + sessionManager = sessionManager, eglBase = { eglBase }, - callProvider = { this }, + mediaManagerFactory = mediaManagerFactory, ) /** @@ -339,7 +437,7 @@ public class Call( internal val mediaManager get() = media.mediaManager /** Delegate that reacts to device connectivity changes (reconnect / leave-on-timeout). */ - private val connectivityMonitor = CallConnectivityMonitor( + private val connectivityMonitor: CallConnectivityMonitor = CallConnectivityMonitor( type = type, id = id, clientImpl = clientImpl, @@ -350,16 +448,50 @@ public class Call( reconnectDeadlineMillis = { reconnectDeadlineMillis }, ) - /** Delegate that drives the join flow (permissions, retry loop, session creation). */ - private val joinCoordinator = CallJoinCoordinator(this) + /** Delegate that restarts ICE when the publisher/subscriber connections drop. */ + private val iceMonitor: CallIceConnectionMonitor = + CallIceConnectionMonitor(type, id, scope, sessionManager) - internal var reconnectDeadlineMillis: Int = 10_000 + /** Delegate that owns the SFU signal/event observers and (re)wires per-session monitoring. */ + private val sessionMonitor: SessionMonitor = SessionMonitor( + type = type, + id = id, + scope = scope, + state = state, + sessionManager = sessionManager, + statsReporter = statsReporter, + iceMonitor = iceMonitor, + connectivityMonitor = connectivityMonitor, + callAnalytics = callAnalytics, + ) - private var sfuListener: Job? = null - private var sfuEvents: Job? = null + /** Delegate that drives the join flow (permissions, retry loop, session creation). */ + private val joinCoordinator = CallJoinCoordinator( + clientImpl = clientImpl, + state = state, + callAnalytics = callAnalytics, + type = type, + id = id, + scope = scope, + sessionManager = sessionManager, + sessionFactory = sessionFactory, + media = media, + lifecycle = lifecycle, + apiClient = apiClient, + reconnector = reconnector, + sessionMonitor = sessionMonitor, + callRegistry = callRegistry, + hasRequiredPermissions = { + clientImpl.permissionCheck + .checkAndroidPermissionsGroup(clientImpl.context, this@Call).first + }, + ) - /** Delegate that restarts ICE when the publisher/subscriber connections drop. */ - private val iceMonitor = CallIceConnectionMonitor(type, id, scope, session) + internal var reconnectDeadlineMillis: Int + get() = sessionManager.reconnectDeadlineMillis + set(value) { + sessionManager.reconnectDeadlineMillis = value + } init { media.startAudioLevelMonitoring() @@ -368,18 +500,6 @@ public class Call( } } - /** Stops the ICE and connectivity monitors (used during teardown). */ - internal fun stopConnectionMonitors() { - iceMonitor.stop() - connectivityMonitor.cancelLeaveTimeout() - connectivityMonitor.unsubscribe() - } - - /** Stops periodic WebRTC stats reporting (used during teardown). */ - internal fun stopStatsReporting() { - statsReporter.stop() - } - /** Basic crud operations */ suspend fun get(): Result = apiClient.get() @@ -459,12 +579,6 @@ public class Call( joinAnalyticsModel, ) - /** Cancels the SFU socket observers (signal WS + fast-reconnect deadline listener). */ - internal fun cancelSfuObservers() { - sfuEvents?.cancel() - sfuListener?.cancel() - } - /** Resets the leave guard so a fresh join can run after a previous leave. */ internal fun resetLeaveGuard() = lifecycle.resetLeaveGuard() @@ -472,30 +586,6 @@ public class Call( internal fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) = media.updateMediaManagerFromSettings(callSettings) - internal fun monitorSession(result: JoinCallResponse) { - sfuEvents?.cancel() - sfuListener?.cancel() - statsReporter.start(result.statsOptions.reportingIntervalMs.toLong()) - // listen to Signal WS - sfuEvents = scope.launch { - session.value?.let { - it.socket.events().collect { event -> - if (event is JoinCallResponseEvent) { - reconnectDeadlineMillis = event.fastReconnectDeadlineSeconds * 1000 - logger.d { "[join] #deadline for reconnect is ${reconnectDeadlineMillis / 1000} seconds" } - } - } - } - } - callAnalytics.peerConnectionAnalytics.stopAndObservePeerConnections(session) - callAnalytics.audioAnalytics.observeFirstRemoteParticipantAudioMuteState( - session, - state.participants, - ) - iceMonitor.start() - connectivityMonitor.subscribe() - } - internal suspend fun collectStats(): CallStatsReport = statsReporter.collectStats() // region Reconnection — unified loop @@ -730,9 +820,6 @@ public class Call( screenShare: Boolean = false, ): Result = apiClient.muteUsers(userIds, audio, video, screenShare) - /** Returns a snapshot of failed SFU IDs to send as migrating_from_list. */ - internal fun getFailedSfuIdsSnapshot(): List = reconnector.getFailedSfuIdsSnapshot() - /** * Called by [RtcSession] when connection to the SFU is established successfully. * Clears the failed SFU list so we don't exclude this SFU on future requests. @@ -764,15 +851,6 @@ public class Call( fun cleanup() = lifecycle.cleanup() - // This will allow the Rest APIs to be executed which are in queue before leave - internal fun shutDownJobsGracefully() { - UserScope(ClientScope()).launch { - supervisorJob.children.forEach { it.join() } - supervisorJob.cancel() - } - scope.cancel() - } - suspend fun ring(): Result = apiClient.ring() suspend fun ring(ringCallRequest: RingCallRequest): Result = diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt index 501a8f3c868..f021067953a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt @@ -59,22 +59,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.threeten.bp.OffsetDateTime -/** - * Reflects this call's ringing-lifecycle transitions into the shared client state. - * - * These are identity hand-offs (the owning call registers itself with client state), so - * they're provided by the call rather than reached into from this component. - * - * Outgoing ringing is split into two steps because [CallState.updateFromResponse] reads - * the current ringingCall: the call must be set as the ringing call *before* the state - * update (which reads it) and added to the ringing-call registry *after* it. - */ -internal interface RingingCallRegistrar { - fun beforeOutgoingStateUpdate() - fun afterOutgoingStateUpdate() - fun onAccepted() -} - /** * Wraps all coordinator (REST) API calls for a call. Each method delegates to the * coordinator client and, where relevant, updates [CallState] from the response. @@ -89,7 +73,7 @@ internal class CallApiClient( private val clientImpl: StreamVideoClient, private val scope: CoroutineScope, private val callSessionId: () -> String, - private val ringRegistrar: RingingCallRegistrar, + private val callRegistry: ClientCallRegistry, ) { private val logger by taggedLogger("Call:ApiClient:$type:$id") @@ -141,14 +125,14 @@ internal class CallApiClient( } response.onSuccess { - // Ordering matters: the ringing call must be registered before the state - // update (which reads ringingCall) and added after. See OutgoingRingRegistrar. + // Ordering matters: updateFromResponse reads the client's ringing call, so this + // call has to occupy that slot before the update and join the registry after it. if (ring) { - ringRegistrar.beforeOutgoingStateUpdate() + callRegistry.markRinging() } state.updateFromResponse(it) if (ring) { - ringRegistrar.afterOutgoingStateUpdate() + callRegistry.registerOutgoingRing() } } return response @@ -370,7 +354,7 @@ internal class CallApiClient( logger.d { "[accept] #ringing; no args, call_id:$id" } state.acceptedOnThisDevice = true - ringRegistrar.onAccepted() + callRegistry.markAccepted() return clientImpl.accept(type, id) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt index 4bdcdea0767..0c9e0d49e88 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt @@ -17,10 +17,8 @@ package io.getstream.video.android.core.call.components import io.getstream.log.taggedLogger -import io.getstream.video.android.core.call.RtcSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -35,7 +33,7 @@ internal class CallIceConnectionMonitor( private val type: String, private val id: String, private val scope: CoroutineScope, - private val session: MutableStateFlow, + private val sessionManager: CallSessionManager, ) { private val logger by taggedLogger("Call:IceMonitor:$type:$id") @@ -50,7 +48,7 @@ internal class CallIceConnectionMonitor( private fun startPublisherMonitor() { monitorPublisherPCStateJob?.cancel() monitorPublisherPCStateJob = scope.launch { - session + sessionManager.session .filterNotNull() .flatMapLatest { it.publisher.filterNotNull() } .flatMapLatest { publisher -> @@ -74,10 +72,10 @@ internal class CallIceConnectionMonitor( private fun startSubscriberMonitor() { monitorSubscriberPCStateJob?.cancel() monitorSubscriberPCStateJob = scope.launch { - session.value?.subscriber?.value?.iceState?.collect { + sessionManager.session.value?.subscriber?.value?.iceState?.collect { when (it) { PeerConnection.IceConnectionState.FAILED, PeerConnection.IceConnectionState.DISCONNECTED -> { - session.value?.requestSubscriberIceRestart() + sessionManager.session.value?.requestSubscriberIceRestart() } else -> { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt index bb8339fe5ef..94952d034d2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -25,11 +25,13 @@ import io.getstream.result.Result.Failure import io.getstream.result.Result.Success import io.getstream.result.flatMap import io.getstream.video.android.core.BackendCause -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallJoinInterceptor import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.CallState import io.getstream.video.android.core.CreateCallOptions import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.StreamVideoClient +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 @@ -37,26 +39,36 @@ import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectFailureCause import io.getstream.video.android.core.call.SfuConnectionResult import io.getstream.video.android.core.model.toIceServer +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import stream.video.sfu.models.WebsocketReconnectStrategy /** - * Drives the join flow for a [Call]: permission checks, the bounded retry loop, the + * Drives the join flow for a call: permission checks, the bounded retry loop, the * underlying join request to the coordinator, and creation + connection of the [RtcSession]. */ internal class CallJoinCoordinator( - private val call: Call, + private val clientImpl: StreamVideoClient, + private val state: CallState, + private val callAnalytics: CallAnalytics, + private val type: String, + private val id: String, + private val scope: CoroutineScope, + private val sessionManager: CallSessionManager, + private val sessionFactory: RtcSessionFactory, + private val media: CallMediaManager, + private val lifecycle: CallLifecycleManager, + private val apiClient: CallApiClient, + private val reconnector: CallReconnector, + private val sessionMonitor: SessionMonitor, + private val callRegistry: ClientCallRegistry, + private val hasRequiredPermissions: () -> Boolean, ) { - private val logger by taggedLogger("Call:JoinCoordinator:${call.type}:${call.id}") + private val logger by taggedLogger("Call:JoinCoordinator:$type:$id") - private val clientImpl get() = call.clientImpl - private val state get() = call.state - private val session get() = call.session - private val callAnalytics get() = call.callAnalytics - private val type get() = call.type - private val id get() = call.id + private fun isVideoEnabled(): Boolean = state.settings.value?.video?.enabled ?: false suspend fun join( create: Boolean = false, @@ -71,10 +83,8 @@ internal class CallJoinCoordinator( logger.d { "[join] #ringing; #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" } - val permissionPass = - clientImpl.permissionCheck.checkAndroidPermissionsGroup(clientImpl.context, call) // Check android permissions and log a warning to make sure developers requested adequate permissions prior to using the call. - if (!permissionPass.first) { + if (!hasRequiredPermissions()) { logger.w { "\n[Call.join()] called without having the required permissions.\n" + "This will work only if you have [runForegroundServiceForCalls = false] in the StreamVideoBuilder.\n" + @@ -88,7 +98,7 @@ internal class CallJoinCoordinator( clientImpl.guestUserJob?.await() // Ensure factory is created with the current audioBitrateProfile before joining - call.ensureFactoryMatchesAudioProfile() + media.ensureFactoryMatchesAudioProfile() state.callJoinInterceptor = callJoinInterceptor @@ -100,7 +110,7 @@ internal class CallJoinCoordinator( var result: Result - call.resetLeaveGuard() + lifecycle.resetLeaveGuard() while (retryCount < 3) { result = joinInternal( create, @@ -116,7 +126,7 @@ internal class CallJoinCoordinator( // the settings during a call. val settings = state.settings.value if (settings != null) { - call.updateMediaManagerFromSettings(settings) + media.updateMediaManagerFromSettings(settings) } else { logger.w { "[join] Call settings were null - this should never happen after a call" + @@ -126,7 +136,7 @@ internal class CallJoinCoordinator( return result } if (result is Failure) { - session.value = null + sessionManager.setActiveSession(null) logger.e { "Join failed with error $result" } if (isPermanentError(result.value)) { state._connection.value = RealtimeConnection.Failed(result.value) @@ -142,7 +152,7 @@ internal class CallJoinCoordinator( } delay((retryCount - 1) * 1000L) } - session.value = null + sessionManager.setActiveSession(null) val errorMessage = "Join failed after 3 retries" state._connection.value = RealtimeConnection.Failed(errorMessage) callAnalytics.joinAnalytics.onJoinRequestRetryExhausted( @@ -156,7 +166,7 @@ internal class CallJoinCoordinator( suspend fun joinAndRing( members: List, createOptions: CreateCallOptions? = CreateCallOptions(members), - video: Boolean = call.isVideoEnabled(), + video: Boolean = isVideoEnabled(), callJoinInterceptor: CallJoinInterceptor? = null, ): Result { logger.d { "[joinAndRing] #ringing; #track; members: $members, video: $video" } @@ -167,14 +177,14 @@ internal class CallJoinCoordinator( callJoinInterceptor = callJoinInterceptor, ).flatMap { rtcSession -> logger.d { "[joinAndRing] Joined #ringing; #track; ring: $members" } - call.ring(RingCallRequest(call.isVideoEnabled(), members)).map { + apiClient.ring(RingCallRequest(isVideoEnabled(), members)).map { logger.d { "[joinAndRing] Ringed #ringing; #track; ring: $members" } - clientImpl.state._ringingCall.value = call + callRegistry.markRinging() rtcSession }.onError { logger.e { "[joinAndRing] Ring failed #ringing; #track; error: $it" } state.toggleJoinAndRingProgress(false) - call.leave( + lifecycle.leave( CallLeaveReason.Backend( BackendCause.RING_FAILED, message = "ring-failed (${it.message})", @@ -201,24 +211,24 @@ internal class CallJoinCoordinator( hintHighScaleLivestreamPublisher: Boolean? = null, joinAnalyticsModel: JoinAnalyticsModel, ): Result { - call.nonFastReconnectAttempts = 0 - call.cancelSfuObservers() + sessionManager.nonFastReconnectAttempts = 0 + sessionMonitor.cancelSfuObservers() - if (session.value != null) { - return Failure(Error.GenericError("Call ${call.cid} has already been joined")) + if (sessionManager.session.value != null) { + return Failure(Error.GenericError("Call $type:$id has already been joined")) } logger.d { "[joinInternal] #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" } - call.connectStartTime = System.currentTimeMillis() + sessionManager.connectStartTime = System.currentTimeMillis() // step 1. call the join endpoint to get a list of SFUs val locationResult = clientImpl.getCachedLocation() if (locationResult !is Success) { return locationResult as Failure } - call.location = locationResult.value + sessionManager.location = locationResult.value val options = createOptions ?: if (create) { @@ -227,7 +237,7 @@ internal class CallJoinCoordinator( null } val result = - call.joinRequest( + joinRequest( options, locationResult.value, ring = ring, @@ -244,29 +254,16 @@ internal class CallJoinCoordinator( val sfuWsUrl = result.value.credentials.server.wsEndpoint val sfuName = result.value.credentials.server.edgeName val iceServers = result.value.credentials.iceServers.map { it.toIceServer() } - val localSession = if (call.unitTestRtcSessionFactory != null) { - call.unitTestRtcSessionFactory!!.invoke() - } else { - RtcSession( - sessionId = call.sessionId, - apiKey = clientImpl.apiKey, - lifecycle = clientImpl.coordinatorConnectionModule.lifecycle, - client = call.client, - call = call, - sfuUrl = sfuUrl, - sfuWsUrl = sfuWsUrl, - sfuToken = sfuToken, - sfuName = sfuName, - remoteIceServers = iceServers, - powerManager = call.powerManager, - sfuAnalytics = callAnalytics.sfuAnalytics.apply { - sfuAnalyticsStateHolder.updateSfuId( - sfuName, - ) - }, - ) - } - session.value = localSession + val localSession = sessionFactory.create( + sessionId = sessionManager.sessionId, + sessionCounter = 0, + sfuUrl = sfuUrl, + sfuWsUrl = sfuWsUrl, + sfuToken = sfuToken, + sfuName = sfuName, + iceServers = iceServers, + ) + sessionManager.setActiveSession(localSession) state._connection.value = RealtimeConnection.Joined(localSession) @@ -287,8 +284,8 @@ internal class CallJoinCoordinator( logger.w { "[_join] SFU socket state observation timed out with no recovery started — triggering REJOIN" } - call.scope.launch { - call.reconnect( + scope.launch { + reconnector.reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, "join-recoverable-connect-failure", ) @@ -325,16 +322,16 @@ internal class CallJoinCoordinator( } } } - val connectedSession = session.value + val connectedSession = sessionManager.session.value ?: return Failure(Error.GenericError("RtcSession was cleared during connection to sfu")) - call.client.state.setActiveCall(call) + callRegistry.markActive() // rejoin/migrate recovery swaps in a NEW session and already calls monitorSession() // with the recovered join response. fastReconnect recovery — and the normal success // path — keep the original session, which is not monitored anywhere else. Only // (re)establish monitoring when the session is unchanged, using the response that // still matches it, so we neither double-register nor monitor with a stale response. if (connectedSession === localSession) { - call.monitorSession(result.value) + sessionMonitor.monitorSession(result.value) } return Success(value = connectedSession) } @@ -349,7 +346,7 @@ internal class CallJoinCoordinator( private fun sendJoinErrorAnalytics(failure: SfuConnectionResult.Failure) { callAnalytics.sfuAnalytics.onSfuWsCompleted( success = false, - retryCount = session.value?.sfuWsRetryCount?.get() ?: 0, + retryCount = sessionManager.session.value?.sfuWsRetryCount?.get() ?: 0, failureReason = failure.error.message, failureCode = (failure.abortReason ?: AnalyticsCallAbortReason.SFU_ERROR).name, ) @@ -382,7 +379,7 @@ internal class CallJoinCoordinator( joinAnalyticsModel: JoinAnalyticsModel, ): Result { val migratingFromList = - migratingFromList ?: call.getFailedSfuIdsSnapshot().takeIf { it.isNotEmpty() } + migratingFromList ?: reconnector.getFailedSfuIdsSnapshot().takeIf { it.isNotEmpty() } callAnalytics.joinAnalytics.onJoinRequestStart(joinAnalyticsModel.joinReason) val result = clientImpl.joinCall( type, id, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt index 6d4465befaf..8636cf3ea13 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -18,30 +18,49 @@ package io.getstream.video.android.core.call.components import io.getstream.log.taggedLogger import io.getstream.result.Result -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.CallState import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.SdkCause import io.getstream.video.android.core.StreamVideoClient -import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController +import io.getstream.video.android.core.analytics.call.CallAnalytics +import io.getstream.video.android.core.call.scope.ScopeProvider import io.getstream.video.android.core.utils.AtomicUnitCall import io.getstream.video.android.core.utils.safeCall import kotlinx.coroutines.launch /** - * Owns the call's lifecycle / teardown for a [Call]: leaving, ending, the single-shot - * leave guard ([AtomicUnitCall]), the destroyed flag, and the ordered cleanup of state, - * session, jobs and media. + * Owns the call's lifecycle / teardown: leaving, ending, the single-shot leave guard + * ([AtomicUnitCall]), the destroyed flag, and the ordered cleanup of state, session, + * jobs and media. + * + * This component is constructed early in [io.getstream.video.android.core.Call] (the reconnect + * and event pipeline depend on it), so collaborators created later are injected as lazy + * providers rather than eager values. */ internal class CallLifecycleManager( - private val call: Call, + private val clientImpl: StreamVideoClient, + private val sessionManager: CallSessionManager, + private val scopeProvider: ScopeProvider, + private val callRegistry: ClientCallRegistry, + /** Cancels the call's supervisor job and scope once in-flight children complete. */ + private val shutDownJobs: () -> Unit, + // Lazy providers: constructed after this component during Call initialization. + private val stateProvider: () -> CallState, + private val callAnalyticsProvider: () -> CallAnalytics, + private val statsReporter: () -> CallStatsReporter, + private val media: () -> CallMediaManager, + private val sessionMonitor: () -> SessionMonitor, + private val iceMonitor: () -> CallIceConnectionMonitor, + private val connectivityMonitor: () -> CallConnectivityMonitor, + private val type: String, + private val id: String, ) { - private val logger by taggedLogger("Call:LifecycleManager:${call.type}:${call.id}") + private val logger by taggedLogger("Call:LifecycleManager:$type:$id") - private val clientImpl get() = call.clientImpl - private val state get() = call.state - private val session get() = call.session - private val callAnalytics get() = call.callAnalytics + private val cid = "$type:$id" + private val state get() = stateProvider() + private val callAnalytics get() = callAnalyticsProvider() // Atomic controls private var atomicLeave = AtomicUnitCall() @@ -61,21 +80,21 @@ internal class CallLifecycleManager( } fun leave(reason: CallLeaveReason) { - logger.d { "[leave] #ringing; call_cid:${call.cid}" } + logger.d { "[leave] #ringing; call_cid:$cid" } internalLeave(reason) } fun leave(reason: String = "user") { - logger.d { "[leave] #ringing; no args, call_cid:${call.cid}" } + logger.d { "[leave] #ringing; no args, call_cid:$cid" } internalLeave(CallLeaveReason.Custom(reason)) } private fun internalLeave(reason: CallLeaveReason) = atomicLeave { - call.stopConnectionMonitors() + stopConnectionMonitors() callAnalytics.stopObservers() - call.cancelSfuObservers() + sessionMonitor().cancelSfuObservers() state._connection.value = RealtimeConnection.Disconnected - logger.v { "[leave] #ringing; call_id = ${call.id}" } + logger.v { "[leave] #ringing; call_id = $id" } if (isDestroyed) { logger.w { "[leave] #ringing; Call already destroyed, ignoring" } return@atomicLeave @@ -87,33 +106,20 @@ internal class CallLifecycleManager( /** * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) */ - call.stopScreenSharing() - call.camera.disable() - call.microphone.disable() - - if (call.id == call.client.state.activeCall.value?.id) { - call.client.state.removeActiveCall(call) // Will also stop CallService - } - - if (call.id == call.client.state.ringingCall.value?.id) { - call.client.state.removeRingingCall(call) - } - - TelecomCallController(call.client.context) - .leaveCall(call) + media().disableLocalCapture() - (call.client as StreamVideoClient).onCallCleanUp(call) + callRegistry.detach() clientImpl.scope.launch { val leaveReason = "[reason=${reason::class.simpleName}, message=${reason.message}]" - callAnalytics.onCallLeave(session, reason) + callAnalytics.onCallLeave(sessionManager.session, reason) safeCall { - session.value?.sfuTracer?.trace("leave-call", leaveReason) - val stats = call.collectStats() - session.value?.sendCallStats(stats) + sessionManager.session.value?.sfuTracer?.trace("leave-call", leaveReason) + val stats = statsReporter().collectStats() + sessionManager.session.value?.sendCallStats(stats) } // Must complete before cleanup() cancels the session's supervisor job. - safeCall { session.value?.sendLeaveEvent(leaveReason) } + safeCall { sessionManager.session.value?.sendLeaveEvent(leaveReason) } cleanup() } } @@ -121,7 +127,7 @@ internal class CallLifecycleManager( /** ends the call for yourself as well as other users */ suspend fun end(): Result { // end the call for everyone - val result = clientImpl.endCall(call.type, call.id) + val result = clientImpl.endCall(type, id) // cleanup leave( CallLeaveReason.SdkDriven( @@ -135,12 +141,19 @@ internal class CallLifecycleManager( fun cleanup() { // monitor.stop() state.cleanup() - session.value?.cleanup() - call.shutDownJobsGracefully() - call.stopStatsReporting() - call.mediaManager.cleanup() // TODO Rahul, Verify Later: need to check which call has owned the media at the moment(probably use active call) - session.value = null + sessionManager.session.value?.cleanup() + shutDownJobs() + statsReporter().stop() + media().cleanup() // TODO Rahul, Verify Later: need to check which call has owned the media at the moment(probably use active call) + sessionManager.setActiveSession(null) // Cleanup the call's scope provider - call.scopeProvider.cleanup() + scopeProvider.cleanup() + } + + /** Stops the ICE and connectivity monitors. */ + private fun stopConnectionMonitors() { + iceMonitor().stop() + connectivityMonitor().cancelLeaveTimeout() + connectivityMonitor().unsubscribe() } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt index 9366889bbf7..a35fee18365 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -22,19 +22,16 @@ import io.getstream.android.video.generated.models.CallSettingsResponse import io.getstream.android.video.generated.models.OwnCapability import io.getstream.android.video.generated.models.VideoSettingsResponse import io.getstream.log.taggedLogger -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallState import io.getstream.video.android.core.CameraDirection import io.getstream.video.android.core.DeviceStatus import io.getstream.video.android.core.MediaManagerImpl import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.audio.StreamAudioDevice -import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.getstream.video.android.core.call.utils.SoundInputProcessor import io.getstream.video.android.core.utils.RampValueUpAndDownHelper import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -49,8 +46,8 @@ import org.webrtc.audio.JavaAudioDeviceModule.AudioSamples * * @param eglBase provider for the shared EGL context; invoked lazily so the native context * isn't created until the media manager / factory actually needs it. - * @param callProvider supplies the owning call, needed only to build [MediaManagerImpl] (a - * public type that takes a [Call]); invoked lazily when the media manager is created. + * @param mediaManagerFactory creates the [MediaManagerImpl]; owned by the Call facade so the + * public type's `call` dependency never leaks into this component. */ internal class CallMediaManager( private val type: String, @@ -58,9 +55,9 @@ internal class CallMediaManager( private val clientImpl: StreamVideoClient, private val scope: CoroutineScope, private val state: CallState, - private val session: MutableStateFlow, + private val sessionManager: CallSessionManager, private val eglBase: () -> EglBase, - private val callProvider: () -> Call, + private val mediaManagerFactory: MediaManagerFactory, ) { private val logger by taggedLogger("Call:MediaManager:$type:$id") @@ -97,17 +94,10 @@ internal class CallMediaManager( } val mediaManager by lazy { - if (Call.testInstanceProvider.mediaManagerCreator != null) { - Call.testInstanceProvider.mediaManagerCreator!!.invoke() - } else { - MediaManagerImpl( - clientImpl.context, - callProvider(), - scope, - eglBase().eglBaseContext, - clientImpl.callServiceConfigRegistry.get(type).audioUsage, - ) { clientImpl.callServiceConfigRegistry.get(type).audioUsage } - } + mediaManagerFactory.create( + audioUsage = clientImpl.callServiceConfigRegistry.get(type).audioUsage, + audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, + ) } /** Starts streaming smoothed microphone audio levels into [localMicrophoneAudioLevel]. */ @@ -257,7 +247,7 @@ internal class CallMediaManager( includeAudio: Boolean = false, ) { if (state.ownCapabilities.value.contains(OwnCapability.Screenshare)) { - session.value?.setScreenShareTrack() + sessionManager.session.value?.setScreenShareTrack() mediaManager.screenShare.enable( mediaProjectionPermissionResultData, includeAudio = includeAudio, @@ -283,6 +273,13 @@ internal class CallMediaManager( return peerConnectionFactory.toggleAudioProcessing() } + /** Disables all local capture devices. Used when leaving the call. */ + fun disableLocalCapture() { + stopScreenSharing() + mediaManager.camera.disable() + mediaManager.microphone.disable() + } + fun cleanup() { mediaManager.cleanup() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt index 6dfb29defab..34606aa109b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt @@ -19,14 +19,15 @@ package io.getstream.video.android.core.call.components import io.getstream.log.taggedLogger import io.getstream.result.Result.Success import io.getstream.video.android.core.BackendCause -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.CallState import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.StreamVideoClient +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.call.FastReconnectResult -import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectionResult import io.getstream.video.android.core.model.toIceServer import kotlinx.coroutines.delay @@ -59,19 +60,33 @@ private sealed class ReconnectOutcome { } /** - * Owns the unified reconnection state machine for a [Call]: the FAST / REJOIN / MIGRATE + * Owns the unified reconnection state machine for a call: the FAST / REJOIN / MIGRATE * strategies, escalation logic, the single-flight reconnect mutex and the set of failed * SFU edge names used to populate `migrating_from_list`. + * + * Several collaborators are constructed after this component in [io.getstream.video.android.core.Call] + * (the reconnector feeds the event pipeline before [CallState] exists), so those are injected + * as lazy providers rather than eager values. */ internal class CallReconnector( - private val call: Call, + private val clientImpl: StreamVideoClient, + private val sessionManager: CallSessionManager, + private val sessionFactory: RtcSessionFactory, + private val lifecycle: CallLifecycleManager, + // Lazy providers: constructed after the reconnector in Call (avoids construction cycles / + // init-order issues). + private val sessionMonitor: () -> SessionMonitor, + private val stateProvider: () -> CallState, + private val callAnalyticsProvider: () -> CallAnalytics, + private val statsReporter: () -> CallStatsReporter, + private val joinCoordinator: () -> CallJoinCoordinator, + private val type: String, + private val id: String, ) { - private val logger by taggedLogger("Call:Reconnector:${call.type}:${call.id}") + private val logger by taggedLogger("Call:Reconnector:$type:$id") - private val clientImpl get() = call.clientImpl - private val state get() = call.state - private val session get() = call.session - private val callAnalytics get() = call.callAnalytics + private val state get() = stateProvider() + private val callAnalytics get() = callAnalyticsProvider() // Read connectivity from the leaf NetworkStateProvider directly rather than routing // through CallConnectivityMonitor — that back-reference would form a dependency cycle @@ -112,9 +127,9 @@ internal class CallReconnector( val conn = state.connection.value logger.d { "[reconnect] Entry — strategy=$strategy reason=$reason connection=$conn" } - if (call.isDestroyed || conn is RealtimeConnection.Disconnected) { + if (lifecycle.isDestroyed || conn is RealtimeConnection.Disconnected) { logger.d { - "[reconnect] Call already left/destroyed (isDestroyed=${call.isDestroyed}, conn=$conn) — skipping ($reason)" + "[reconnect] Call already left/destroyed (isDestroyed=${lifecycle.isDestroyed}, conn=$conn) — skipping ($reason)" } return } @@ -192,7 +207,7 @@ internal class CallReconnector( } val currentTimeInMillis = System.currentTimeMillis() - if (currentTimeInMillis - loopStartTime >= call.reconnectDeadlineMillis) { + if (currentTimeInMillis - loopStartTime >= sessionManager.reconnectDeadlineMillis) { currentStrategy = when (currentStrategy) { WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_UNSPECIFIED, @@ -214,17 +229,23 @@ internal class CallReconnector( -> reconnectFast(reason) WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN -> { - call.nonFastReconnectAttempts++ + sessionManager.nonFastReconnectAttempts++ reconnectRejoin( reason, - JoinAnalyticsModel(call.nonFastReconnectAttempts, JoinReason.ReJoin), + JoinAnalyticsModel( + sessionManager.nonFastReconnectAttempts, + JoinReason.ReJoin, + ), ) } WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE -> { - call.nonFastReconnectAttempts++ + sessionManager.nonFastReconnectAttempts++ reconnectMigrate( - JoinAnalyticsModel(call.nonFastReconnectAttempts, JoinReason.Migrate), + JoinAnalyticsModel( + sessionManager.nonFastReconnectAttempts, + JoinReason.Migrate, + ), ) } @@ -237,7 +258,7 @@ internal class CallReconnector( is ReconnectOutcome.Disconnect -> { logger.w { "[reconnect] DISCONNECT requested — leaving call" } - call.leave( + lifecycle.leave( CallLeaveReason.Backend(BackendCause.SFU_DISCONNECT), ) break @@ -258,7 +279,7 @@ internal class CallReconnector( is ReconnectOutcome.Failed -> { logger.w { - "[reconnect] $currentStrategy (${call.nonFastReconnectAttempts}) failed: ${outcome.error.message}" + "[reconnect] $currentStrategy (${sessionManager.nonFastReconnectAttempts}) failed: ${outcome.error.message}" } delay(RECONNECT_DELAY_MS) @@ -267,7 +288,7 @@ internal class CallReconnector( val wasMigrating = currentStrategy == WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE val pastFastReconnectDeadline = (System.currentTimeMillis() - loopStartTime) > - call.reconnectDeadlineMillis + sessionManager.reconnectDeadlineMillis val shouldEscalateToRejoin = wasMigrating || pastFastReconnectDeadline @@ -287,7 +308,7 @@ internal class CallReconnector( AnalyticsCallAbortReason.RETRY_EXHAUSTED.name, message, ) - call.leave( + lifecycle.leave( CallLeaveReason.RetryExhausted( loopIteration, "reconnect-failed", @@ -311,16 +332,16 @@ internal class CallReconnector( * SFU already knows this participant. */ private suspend fun reconnectFast(reason: String): ReconnectOutcome { - logger.d { "[reconnectFast] reconnectAttempts=${call.nonFastReconnectAttempts}" } - val currentSession = session.value + logger.d { "[reconnectFast] reconnectAttempts=${sessionManager.nonFastReconnectAttempts}" } + val currentSession = sessionManager.session.value ?: return ReconnectOutcome.PreconditionNotMet("No active session for fast reconnect") - val stats = call.collectStats() + val stats = statsReporter().collectStats() currentSession.sendCallStats(stats) currentSession.prepareReconnect() state._connection.value = RealtimeConnection.Reconnecting - call.reconnectStartTime = System.currentTimeMillis() + sessionManager.reconnectStartTime = System.currentTimeMillis() val (_, subscriptionsInfo, publishingInfo) = currentSession.currentSfuInfo() val reconnectDetails = ReconnectDetails( @@ -328,7 +349,7 @@ internal class CallReconnector( strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, announced_tracks = publishingInfo, subscriptions = subscriptionsInfo, - reconnect_attempt = call.nonFastReconnectAttempts, + reconnect_attempt = sessionManager.nonFastReconnectAttempts, reason = reason, ) return when (val result = currentSession.fastReconnect(reconnectDetails)) { @@ -347,15 +368,18 @@ internal class CallReconnector( reason: String, joinAnalyticsModel: JoinAnalyticsModel, ): ReconnectOutcome { - logger.d { "[reconnectRejoin] reconnectAttempts=${call.nonFastReconnectAttempts}" } + logger.d { "[reconnectRejoin] reconnectAttempts=${sessionManager.nonFastReconnectAttempts}" } state._connection.value = RealtimeConnection.Reconnecting - val loc = call.location + val loc = sessionManager.location ?: return ReconnectOutcome.PreconditionNotMet("No location available for rejoin") - val oldSession = session.value + val oldSession = sessionManager.session.value ?: return ReconnectOutcome.PreconditionNotMet("No active session for rejoin") - call.reconnectStartTime = System.currentTimeMillis() + sessionManager.reconnectStartTime = System.currentTimeMillis() - val joinResponse = call.joinRequest(location = loc, joinAnalyticsModel = joinAnalyticsModel) + val joinResponse = joinCoordinator().joinRequest( + location = loc, + joinAnalyticsModel = joinAnalyticsModel, + ) if (joinResponse !is Success) { return ReconnectOutcome.Failed( Exception("Failed to get join response: ${joinResponse.errorOrNull()}"), @@ -366,38 +390,28 @@ internal class CallReconnector( val currentOptions = oldSession.publisher.value?.currentOptions() logger.i { "Rejoin SFU ${oldSession.sfuUrl} to ${cred.server.url}" } - call.sessionId = UUID.randomUUID().toString() + sessionManager.sessionId = UUID.randomUUID().toString() val (prevSessionId, subscriptionsInfo, publishingInfo) = oldSession.currentSfuInfo() val reconnectDetails = ReconnectDetails( previous_session_id = prevSessionId, strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, announced_tracks = publishingInfo, subscriptions = subscriptionsInfo, - reconnect_attempt = call.nonFastReconnectAttempts, + reconnect_attempt = sessionManager.nonFastReconnectAttempts, reason = reason, ) - call.state.removeParticipant(prevSessionId) + state.removeParticipant(prevSessionId) oldSession.prepareRejoin("rejoin") - val newSession = call.unitTestRtcSessionFactory?.invoke() ?: RtcSession( - clientImpl, - call.nonFastReconnectAttempts, - call.powerManager, - call, - call.sessionId, - clientImpl.apiKey, - clientImpl.coordinatorConnectionModule.lifecycle, - cred.server.url, - cred.server.wsEndpoint, - cred.token, - cred.server.edgeName, - cred.iceServers.map { ice -> ice.toIceServer() }, - sfuAnalytics = callAnalytics.sfuAnalytics.apply { - sfuAnalyticsStateHolder.updateSfuId( - cred.server.edgeName, - ) - }, + val newSession = sessionFactory.create( + sessionId = sessionManager.sessionId, + sessionCounter = sessionManager.nonFastReconnectAttempts, + sfuUrl = cred.server.url, + sfuWsUrl = cred.server.wsEndpoint, + sfuToken = cred.token, + sfuName = cred.server.edgeName, + iceServers = cred.iceServers.map { ice -> ice.toIceServer() }, ) - session.value = newSession + sessionManager.setActiveSession(newSession) return when ( val result = newSession.connectInternal( @@ -407,7 +421,7 @@ internal class CallReconnector( ) { is SfuConnectionResult.Success -> { newSession.sfuTracer.trace("rejoin", reason) - call.monitorSession(joinResponse.value) + sessionMonitor().monitorSession(joinResponse.value) ReconnectOutcome.Success } is SfuConnectionResult.Failure -> ReconnectOutcome.Failed(result.error) @@ -421,15 +435,15 @@ internal class CallReconnector( private suspend fun reconnectMigrate(joinAnalyticsModel: JoinAnalyticsModel): ReconnectOutcome { logger.d { "[reconnectMigrate] Migrating" } state._connection.value = RealtimeConnection.Migrating - val loc = call.location + val loc = sessionManager.location ?: return ReconnectOutcome.PreconditionNotMet("No location available for migrate") - val oldSession = session.value + val oldSession = sessionManager.session.value ?: return ReconnectOutcome.PreconditionNotMet("No active session for migrate") - call.reconnectStartTime = System.currentTimeMillis() + sessionManager.reconnectStartTime = System.currentTimeMillis() addFailedSfuId(oldSession.sfuName) val joinResponse = - call.joinRequest( + joinCoordinator().joinRequest( location = loc, migratingFrom = oldSession.sfuName, joinAnalyticsModel = joinAnalyticsModel, @@ -454,33 +468,23 @@ internal class CallReconnector( announced_tracks = publishingInfo, subscriptions = subscriptionsInfo, from_sfu_id = oldSfuName, - reconnect_attempt = call.nonFastReconnectAttempts, + reconnect_attempt = sessionManager.nonFastReconnectAttempts, ) - val stats = call.collectStats() + val stats = statsReporter().collectStats() oldSession.sendCallStats(stats) oldSession.enterMigration() - val newSession = call.unitTestRtcSessionFactory?.invoke() ?: RtcSession( - clientImpl, - call.nonFastReconnectAttempts, - call.powerManager, - call, - call.sessionId, - clientImpl.apiKey, - clientImpl.coordinatorConnectionModule.lifecycle, - cred.server.url, - cred.server.wsEndpoint, - cred.token, - cred.server.edgeName, - cred.iceServers.map { ice -> ice.toIceServer() }, - sfuAnalytics = callAnalytics.sfuAnalytics.apply { - sfuAnalyticsStateHolder.updateSfuId( - cred.server.edgeName, - ) - }, + val newSession = sessionFactory.create( + sessionId = sessionManager.sessionId, + sessionCounter = sessionManager.nonFastReconnectAttempts, + sfuUrl = cred.server.url, + sfuWsUrl = cred.server.wsEndpoint, + sfuToken = cred.token, + sfuName = cred.server.edgeName, + iceServers = cred.iceServers.map { ice -> ice.toIceServer() }, ) - session.value = newSession + sessionManager.setActiveSession(newSession) return try { val result = newSession.connectInternal( @@ -489,7 +493,7 @@ internal class CallReconnector( ) when (result) { is SfuConnectionResult.Success -> { - call.monitorSession(joinResponse.value) + sessionMonitor().monitorSession(joinResponse.value) ReconnectOutcome.Success } is SfuConnectionResult.Failure -> ReconnectOutcome.Failed(result.error) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt index 693b0c53a2b..e5510534fd5 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt @@ -19,7 +19,6 @@ package io.getstream.video.android.core.call.components import android.graphics.Bitmap import io.getstream.log.taggedLogger import io.getstream.video.android.core.analytics.call.CallAnalytics -import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.connection.Subscriber import io.getstream.video.android.core.call.video.YuvFrame import io.getstream.video.android.core.model.AudioTrack @@ -27,7 +26,6 @@ import io.getstream.video.android.core.model.PreferredVideoResolution import io.getstream.video.android.core.model.VideoTrack import io.getstream.webrtc.android.ui.VideoTextureViewRenderer import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import org.webrtc.EglBase @@ -49,7 +47,7 @@ internal class CallRenderer( private val type: String, private val id: String, private val scope: CoroutineScope, - private val session: MutableStateFlow, + private val sessionManager: CallSessionManager, private val callAnalytics: CallAnalytics, private val eglBase: () -> EglBase, private val callSessionId: () -> String, @@ -65,7 +63,7 @@ internal class CallRenderer( logger.i { "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" } - session.value?.updateTrackDimensions( + sessionManager.session.value?.updateTrackDimensions( sessionId, trackType, visible, @@ -85,7 +83,7 @@ internal class CallRenderer( logger.i { "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" } - session.value?.updateTrackDimensions( + sessionManager.session.value?.updateTrackDimensions( sessionId, trackType, visible, @@ -116,7 +114,7 @@ internal class CallRenderer( "sessionId: $sessionId" } if (trackType != TrackType.TRACK_TYPE_SCREEN_SHARE) { - session.value?.updateTrackDimensions( + sessionManager.session.value?.updateTrackDimensions( sessionId, trackType, true, @@ -130,7 +128,7 @@ internal class CallRenderer( trackType, width, height, - rtcSession = session.value, + rtcSession = sessionManager.session.value, sessionId, callSessionId(), ) @@ -152,7 +150,7 @@ internal class CallRenderer( } if (trackType != TrackType.TRACK_TYPE_SCREEN_SHARE) { - session.value?.updateTrackDimensions( + sessionManager.session.value?.updateTrackDimensions( sessionId, trackType, true, @@ -194,7 +192,7 @@ internal class CallRenderer( resolution: PreferredVideoResolution?, sessionIds: List? = null, ) { - session.value?.let { session -> + sessionManager.session.value?.let { session -> session.trackOverridesHandler.updateOverrides( sessionIds = sessionIds, dimensions = resolution?.let { VideoDimension(it.width, it.height) }, @@ -203,11 +201,14 @@ internal class CallRenderer( } fun setIncomingVideoEnabled(enabled: Boolean?, sessionIds: List? = null) { - session.value?.trackOverridesHandler?.updateOverrides(sessionIds, visible = enabled) + sessionManager.session.value?.trackOverridesHandler?.updateOverrides( + sessionIds, + visible = enabled, + ) } fun setIncomingAudioEnabled(enabled: Boolean, sessionIds: List? = null) { - val participantTrackMap = session.value?.subscriber?.value?.tracks ?: return + val participantTrackMap = sessionManager.session.value?.subscriber?.value?.tracks ?: return val targetTracks = when { sessionIds != null -> sessionIds.mapNotNull { participantTrackMap[it] } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt index 4b4a50704af..96aa0e67440 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt @@ -16,13 +16,14 @@ package io.getstream.video.android.core.call.components -import io.getstream.video.android.core.Call import io.getstream.video.android.core.call.RtcSession import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import java.util.UUID /** - * Owns the live RTC session state for a [Call] and the bookkeeping shared across the + * Owns the live RTC session state for a call and the bookkeeping shared across the * join / reconnect flows: the current [session], the participant [sessionId], the cached * SFU [location], reconnect attempt counters and connect/reconnect timestamps. * @@ -30,8 +31,19 @@ import java.util.UUID * lifecycle collaborators a single source of truth to depend on. */ internal class CallSessionManager() { - /** Session handles all real time communication for video and audio. */ - val session: MutableStateFlow = MutableStateFlow(null) + private val _session: MutableStateFlow = MutableStateFlow(null) + + /** + * The live RTC session that handles all real time communication for video and audio. + * Read-only to collaborators — mutate exclusively through [setActiveSession] so there is a + * single, unidirectional write path for the session. + */ + val session: StateFlow = _session.asStateFlow() + + /** Sole write path for [session]: swaps in a new RTC session or clears it with `null`. */ + fun setActiveSession(rtcSession: RtcSession?) { + _session.value = rtcSession + } var sessionId = UUID.randomUUID().toString() @@ -45,4 +57,12 @@ internal class CallSessionManager() { var connectStartTime = 0L var reconnectStartTime = 0L + + /** + * Fast-reconnect deadline (in millis), updated at runtime from the SFU's + * `fastReconnectDeadlineSeconds`. Written by the session observer and read by the + * reconnect loop and the connectivity monitor, so it lives with the other shared + * reconnect bookkeeping. + */ + var reconnectDeadlineMillis: Int = 10_000 } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt index b134667004f..37b383b3db6 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt @@ -19,7 +19,6 @@ package io.getstream.video.android.core.call.components import io.getstream.log.taggedLogger import io.getstream.video.android.core.CallState import io.getstream.video.android.core.CallStatsReport -import io.getstream.video.android.core.call.RtcSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -35,7 +34,7 @@ internal class CallStatsReporter( private val type: String, private val id: String, private val scope: CoroutineScope, - private val session: MutableStateFlow, + private val sessionManager: CallSessionManager, private val state: CallState, ) { private val logger by taggedLogger("Call:StatsReporter:$type:$id") @@ -56,7 +55,7 @@ internal class CallStatsReporter( while (isActive) { delay(reportingIntervalMs) - session.value?.sendCallStats( + sessionManager.session.value?.sendCallStats( report = collectStats(), ) } @@ -68,7 +67,7 @@ internal class CallStatsReporter( } suspend fun collectStats(): CallStatsReport { - val currentSession = session.value + val currentSession = sessionManager.session.value val publisherStats = runCatching { currentSession?.getPublisherStats() }.getOrNull() val subscriberStats = runCatching { currentSession?.getSubscriberStats() }.getOrNull() runCatching { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/ClientCallRegistry.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/ClientCallRegistry.kt new file mode 100644 index 00000000000..084c7e3144e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/ClientCallRegistry.kt @@ -0,0 +1,47 @@ +/* + * 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.call.components + +/** + * Registers and deregisters a call in the client-level registries — the client's ringing and + * active call slots, the system telecom bookkeeping, and the client's per-call cleanup. + * + * Every operation here needs the `Call` instance itself, which is the one thing the extracted + * collaborators deliberately don't hold. [io.getstream.video.android.core.Call] implements this + * as an anonymous object capturing `this`, so the join, API and lifecycle components can drive + * these transitions without a reference to the call. + */ +internal interface ClientCallRegistry { + + /** Points the client's ringing-call slot at this call. */ + fun markRinging() + + /** Registers this call as an outgoing ringing call. */ + fun registerOutgoingRing() + + /** Promotes this call to the client's active call. */ + fun markActive() + + /** Transitions client state for an incoming call accepted on this device. */ + fun markAccepted() + + /** + * Removes this call from the ringing / active registries and telecom bookkeeping, and runs + * the client's per-call cleanup. Called once, while leaving. + */ + fun detach() +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/MediaManagerFactory.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/MediaManagerFactory.kt new file mode 100644 index 00000000000..ab2c56c0376 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/MediaManagerFactory.kt @@ -0,0 +1,33 @@ +/* + * 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.call.components + +import io.getstream.video.android.core.MediaManagerImpl + +/** + * Creates [MediaManagerImpl] instances for a call. + * + * Owned by [io.getstream.video.android.core.Call] so [MediaManagerImpl]'s `call` dependency — + * and the shared EGL context it is built from — stay at the facade boundary and never leak + * into [CallMediaManager]. + */ +internal fun interface MediaManagerFactory { + fun create( + audioUsage: Int, + audioUsageProvider: () -> Int, + ): MediaManagerImpl +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/RtcSessionFactory.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/RtcSessionFactory.kt new file mode 100644 index 00000000000..3d9910d8fdc --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/RtcSessionFactory.kt @@ -0,0 +1,39 @@ +/* + * 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.call.components + +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.model.IceServer + +/** + * Creates [RtcSession] instances for join / rejoin / migrate. + * + * Owned by [io.getstream.video.android.core.Call] so the [RtcSession] constructor's + * `call` dependency stays at the facade boundary and never leaks into the join or + * reconnect orchestrators. + */ +internal fun interface RtcSessionFactory { + fun create( + sessionId: String, + sessionCounter: Int, + sfuUrl: String, + sfuWsUrl: String, + sfuToken: String, + sfuName: String, + iceServers: List, + ): RtcSession +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/SessionMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/SessionMonitor.kt new file mode 100644 index 00000000000..452d52914fc --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/SessionMonitor.kt @@ -0,0 +1,88 @@ +/* + * 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.call.components + +import io.getstream.android.video.generated.models.JoinCallResponse +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.analytics.call.CallAnalytics +import io.getstream.video.android.core.events.JoinCallResponseEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Owns the SFU signal/event observers for a call and (re)wires the per-session monitoring + * whenever a session is established or swapped (join, rejoin, migrate). + * + * Extracted from the [io.getstream.video.android.core.Call] facade so the join and reconnect + * flows can drive session monitoring directly instead of routing through the facade. It owns + * the observer [Job]s it launches and fans out to the stats, ICE and connectivity monitors. + */ +internal class SessionMonitor( + private val type: String, + private val id: String, + private val scope: CoroutineScope, + private val state: CallState, + private val sessionManager: CallSessionManager, + private val statsReporter: CallStatsReporter, + private val iceMonitor: CallIceConnectionMonitor, + private val connectivityMonitor: CallConnectivityMonitor, + private val callAnalytics: CallAnalytics, +) { + private val logger by taggedLogger("Call:SessionMonitor:$type:$id") + + private var sfuListener: Job? = null + private var sfuEvents: Job? = null + + /** Cancels the active SFU signal/event observers, if any. */ + fun cancelSfuObservers() { + sfuEvents?.cancel() + sfuListener?.cancel() + } + + /** + * (Re)establishes monitoring for the current session: restarts stats reporting, subscribes + * to the SFU signal socket (to keep the fast-reconnect deadline fresh), wires the analytics + * observers, and starts the ICE and connectivity monitors. + */ + fun monitorSession(result: JoinCallResponse) { + sfuEvents?.cancel() + sfuListener?.cancel() + statsReporter.start(result.statsOptions.reportingIntervalMs.toLong()) + // listen to Signal WS + sfuEvents = scope.launch { + sessionManager.session.value?.let { + it.socket.events().collect { event -> + if (event is JoinCallResponseEvent) { + sessionManager.reconnectDeadlineMillis = event.fastReconnectDeadlineSeconds * 1000 + logger.d { + "[join] #deadline for reconnect is ${sessionManager.reconnectDeadlineMillis / 1000} seconds" + } + } + } + } + } + callAnalytics.peerConnectionAnalytics.stopAndObservePeerConnections(sessionManager.session) + callAnalytics.audioAnalytics.observeFirstRemoteParticipantAudioMuteState( + sessionManager.session, + state.participants, + ) + iceMonitor.start() + connectivityMonitor.subscribe() + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt new file mode 100644 index 00000000000..b196b908a28 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt @@ -0,0 +1,65 @@ +/* + * 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 + +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.components.CallSessionManager +import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule +import io.getstream.video.android.core.internal.network.NetworkStateProvider +import io.mockk.every +import io.mockk.mockk + +/** + * Seeds an active [RtcSession] on a real [Call], for tests that start from an already-joined + * call (reconnect, migrate, escalation). + * + * [CallSessionManager] is the single writer for the session and [Call] deliberately exposes no + * setter — in production a session only ever appears by joining. Reaching the manager + * reflectively keeps that write path out of the production API instead of adding a facade + * method that exists purely for tests. + */ +internal fun Call.injectSession(session: RtcSession?) { + val field = Call::class.java.getDeclaredField("sessionManager") + field.isAccessible = true + (field.get(this) as CallSessionManager).setActiveSession(session) +} + +/** + * Replaces the device connectivity provider with a mock reporting [connected]. + * + * Injects at the connection module rather than at a single component: the reconnect loop reads + * the provider straight off the module (it deliberately does not go through + * `CallConnectivityMonitor`, which would close a dependency cycle), so replacing it there is + * what makes the mock visible to both the loop and the monitor. The monitor's own cached + * reference is overwritten too, in case it was already resolved. + */ +internal fun Call.injectMockNetwork(connected: Boolean = true) { + val mockNetwork = mockk(relaxed = true) + every { mockNetwork.isConnected() } returns connected + + val moduleField = CoordinatorConnectionModule::class.java + .getDeclaredField("networkStateProvider\$delegate") + moduleField.isAccessible = true + moduleField.set((client as StreamVideoClient).coordinatorConnectionModule, lazyOf(mockNetwork)) + + val monitorField = Call::class.java.getDeclaredField("connectivityMonitor") + monitorField.isAccessible = true + val monitor = monitorField.get(this) + val monitorNetwork = monitor.javaClass.getDeclaredField("network\$delegate") + monitorNetwork.isAccessible = true + monitorNetwork.set(monitor, lazyOf(mockNetwork)) +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt index a06a4f88506..a13aca66445 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt @@ -54,14 +54,14 @@ class CallApiClientTest { private lateinit var clientImpl: StreamVideoClient private lateinit var state: CallState - private lateinit var ringRegistrar: RingingCallRegistrar + private lateinit var callRegistry: ClientCallRegistry private lateinit var apiClient: CallApiClient @Before fun setup() { clientImpl = mockk(relaxed = true) state = mockk(relaxed = true) - ringRegistrar = mockk(relaxed = true) + callRegistry = mockk(relaxed = true) // Response-processing endpoints return Success so the onSuccess/state-update // branches are exercised. @@ -94,7 +94,7 @@ class CallApiClientTest { clientImpl = clientImpl, scope = testScope, callSessionId = { "session-id" }, - ringRegistrar = ringRegistrar, + callRegistry = callRegistry, ) } @@ -130,8 +130,8 @@ class CallApiClientTest { fun `create with ring registers an outgoing ringing call`() = runTest(testDispatcher) { apiClient.create(ring = true) - verify { ringRegistrar.beforeOutgoingStateUpdate() } - verify { ringRegistrar.afterOutgoingStateUpdate() } + verify { callRegistry.markRinging() } + verify { callRegistry.registerOutgoingRing() } } @Test @@ -282,14 +282,14 @@ class CallApiClientTest { coVerify { clientImpl.getOrCreateCallFullMembers(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()) } - verify { ringRegistrar.afterOutgoingStateUpdate() } + verify { callRegistry.registerOutgoingRing() } } @Test fun `accept marks the call accepted on this device`() = runTest(testDispatcher) { apiClient.accept() verify { state.acceptedOnThisDevice = true } - verify { ringRegistrar.onAccepted() } + verify { callRegistry.markAccepted() } } @Test diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt index c8f8736af56..b108dfe4093 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt @@ -43,20 +43,22 @@ class CallIceConnectionMonitorTest { private lateinit var session: RtcSession private lateinit var publisher: Publisher private lateinit var subscriber: Subscriber - private lateinit var sessionFlow: MutableStateFlow + private lateinit var sessionManager: CallSessionManager @Before fun setup() { session = mockk(relaxed = true) publisher = mockk(relaxed = true) subscriber = mockk(relaxed = true) - sessionFlow = MutableStateFlow(session) + sessionManager = CallSessionManager() + sessionManager.setActiveSession(session) every { session.publisher } returns MutableStateFlow(publisher) every { session.subscriber } returns MutableStateFlow(subscriber) } - private fun monitor() = CallIceConnectionMonitor("default", "call-id", testScope, sessionFlow) + private fun monitor() = + CallIceConnectionMonitor("default", "call-id", testScope, sessionManager) @Test fun `failed publisher ice state triggers an ice restart`() = runTest(testDispatcher) { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt index 3f8250de276..f545cc1adfd 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -17,35 +17,31 @@ package io.getstream.video.android.core.call.components import com.google.common.truth.Truth.assertThat +import io.getstream.android.video.generated.models.CallSettingsResponse import io.getstream.android.video.generated.models.JoinCallResponse import io.getstream.android.video.generated.models.RingCallRequest import io.getstream.android.video.generated.models.RingCallResponse import io.getstream.result.Error import io.getstream.result.Result.Failure import io.getstream.result.Result.Success -import io.getstream.video.android.core.Call -import io.getstream.video.android.core.MediaManagerImpl +import io.getstream.video.android.core.CallLeaveReason +import io.getstream.video.android.core.CallState 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.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.base.DispatcherRule import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectFailureCause import io.getstream.video.android.core.call.SfuConnectionResult -import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule -import io.getstream.video.android.core.internal.network.NetworkStateProvider -import io.getstream.video.android.model.User -import io.mockk.MockKAnnotations import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every -import io.mockk.impl.annotations.RelaxedMockK import io.mockk.mockk -import io.mockk.spyk import io.mockk.unmockkAll -import kotlinx.coroutines.CoroutineScope +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -56,10 +52,11 @@ import org.junit.Rule import org.junit.Test /** - * Tests the join orchestration extracted into [CallJoinCoordinator]: the public - * [Call.join] retry loop, join-and-ring, the coordinator's own join request, and the - * permanent-vs-transient error handling. Exercised through the [Call] facade with the - * RtcSession injected via [Call.unitTestRtcSessionFactory]. + * Tests the join orchestration in [CallJoinCoordinator]: the [join] retry loop, join-and-ring, + * the coordinator's own join request, and permanent-vs-transient error handling. The coordinator + * is constructed directly with mocked collaborators so the sibling fan-out (media / lifecycle / + * apiClient / reconnector / sessionMonitor) and the client-state registrations (via + * [ClientCallRegistry]) can be stubbed and verified precisely. */ class CallJoinCoordinatorTest { @@ -69,102 +66,108 @@ class CallJoinCoordinatorTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) - @RelaxedMockK - private lateinit var mockClientImpl: StreamVideoClient - - @RelaxedMockK + private lateinit var clientImpl: StreamVideoClient + private lateinit var state: CallState + private lateinit var callAnalytics: CallAnalytics + private lateinit var sessionManager: CallSessionManager + private lateinit var media: CallMediaManager + private lateinit var lifecycle: CallLifecycleManager + private lateinit var apiClient: CallApiClient + private lateinit var reconnector: CallReconnector + private lateinit var sessionMonitor: SessionMonitor + private lateinit var callRegistry: ClientCallRegistry + + private lateinit var sessionFlow: MutableStateFlow + private lateinit var connectionFlow: MutableStateFlow private lateinit var mockSession: RtcSession - - private lateinit var mockStreamVideo: StreamVideo private lateinit var mockJoinResponse: JoinCallResponse - private lateinit var call: Call @Before fun setup() { - MockKAnnotations.init(this, relaxUnitFun = true) - - mockStreamVideo = mockk(relaxed = true) - StreamVideo.install(mockStreamVideo) - - val mockNetworkStateProvider = mockk(relaxed = true) - every { mockNetworkStateProvider.isConnected() } returns true - val mockCoordinatorModule = mockk(relaxed = true) - every { mockCoordinatorModule.networkStateProvider } returns mockNetworkStateProvider - - every { mockClientImpl.coordinatorConnectionModule } returns mockCoordinatorModule - every { mockClientImpl.scope } returns testScope as CoroutineScope - every { mockClientImpl.leaveAfterDisconnectSeconds } returns 120L - every { mockClientImpl.apiKey } returns "test-api-key" - coEvery { mockClientImpl.getCachedLocation() } returns Success("test-location") - every { - mockClientImpl.permissionCheck.checkAndroidPermissionsGroup(any(), any()) - } returns Pair(true, emptySet()) - + clientImpl = mockk(relaxed = true) + state = mockk(relaxed = true) + callAnalytics = mockk(relaxed = true) + sessionManager = mockk(relaxed = true) + media = mockk(relaxed = true) + lifecycle = mockk(relaxed = true) + apiClient = mockk(relaxed = true) + reconnector = mockk(relaxed = true) + sessionMonitor = mockk(relaxed = true) + callRegistry = mockk(relaxed = true) + mockSession = mockk(relaxed = true) mockJoinResponse = mockk(relaxed = true) - Call.testInstanceProvider.mediaManagerCreator = { mockk(relaxed = true) } + sessionFlow = MutableStateFlow(null) + connectionFlow = MutableStateFlow(RealtimeConnection.InProgress) - call = spyk( - Call( - client = mockClientImpl, - type = "default", - id = "test-call", - user = User(id = "test-user", role = "user"), - ), - ) - repointComponentToSpy("joinCoordinator", call) - call.unitTestRtcSessionFactory = { mockSession } - every { call.monitorSession(any()) } returns Unit + every { sessionManager.session } returns sessionFlow + every { sessionManager.setActiveSession(any()) } answers { sessionFlow.value = firstArg() } + every { state._connection } returns connectionFlow + every { state.connection } returns connectionFlow + every { state.settings } returns MutableStateFlow(null) + coEvery { clientImpl.getCachedLocation() } returns Success("test-location") } @After fun tearDown() { - Call.testInstanceProvider.mediaManagerCreator = null - StreamVideo.removeClient() unmockkAll() } - private fun repointComponentToSpy(fieldName: String, spy: Call) { - val field = Call::class.java.getDeclaredField(fieldName) - field.isAccessible = true - val component = field.get(spy) - val callField = component.javaClass.getDeclaredField("call") - callField.isAccessible = true - callField.set(component, spy) + private fun coordinator() = CallJoinCoordinator( + clientImpl = clientImpl, + state = state, + callAnalytics = callAnalytics, + type = "default", + id = "test-call", + scope = testScope, + sessionManager = sessionManager, + sessionFactory = RtcSessionFactory { _, _, _, _, _, _, _ -> mockSession }, + media = media, + lifecycle = lifecycle, + apiClient = apiClient, + reconnector = reconnector, + sessionMonitor = sessionMonitor, + callRegistry = callRegistry, + hasRequiredPermissions = { true }, + ) + + private fun stubJoinCall(result: io.getstream.result.Result) { + coEvery { + clientImpl.joinCall( + any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), + ) + } returns result } @Test fun `join succeeds and returns the connected session`() = runTest(testDispatcher) { - coEvery { - call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) - } returns Success(mockJoinResponse) - coEvery { mockSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Success - val result = call.join() + val result = coordinator().join() advanceUntilIdle() assertThat(result).isInstanceOf(Success::class.java) assertThat((result as Success).value).isSameInstanceAs(mockSession) - coVerify { call.monitorSession(mockJoinResponse) } + verify { sessionMonitor.monitorSession(mockJoinResponse) } } @Test fun `join fails permanently on a terminal SFU failure without retrying`() = runTest( testDispatcher, ) { - coEvery { - call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) - } returns Success(mockJoinResponse) - coEvery { mockSession.connectInternal(any(), any()) } returns SfuConnectionResult.Failure( + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Failure( Exception("permanent auth error"), cause = SfuConnectFailureCause.TerminalSocketFailure, ) - val result = call.join() + val result = coordinator().join() advanceUntilIdle() assertThat(result).isInstanceOf(Failure::class.java) - assertThat(call.state.connection.value).isInstanceOf(RealtimeConnection.Failed::class.java) + assertThat(connectionFlow.value).isInstanceOf(RealtimeConnection.Failed::class.java) } @Test @@ -172,35 +175,42 @@ class CallJoinCoordinatorTest { testDispatcher, ) { // "Unable to resolve host" is treated as transient, so the loop retries. - coEvery { - call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) - } returns Failure(Error.ThrowableError("Unable to resolve host \"sfu\"", Exception("dns"))) + stubJoinCall( + Failure(Error.ThrowableError("Unable to resolve host \"sfu\"", Exception("dns"))), + ) - val result = call.join() + val result = coordinator().join() advanceUntilIdle() assertThat(result).isInstanceOf(Failure::class.java) - // joinRequest is attempted once per retry (3 attempts total). + // joinRequest -> clientImpl.joinCall is attempted once per retry (3 attempts total). coVerify(exactly = 3) { - call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + clientImpl.joinCall( + any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), + ) } } @Test fun `join fails when the call is already joined`() = runTest(testDispatcher) { - call.session.value = mockk(relaxed = true) + sessionFlow.value = mockk(relaxed = true) - val result = call._join(joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt)) + val result = coordinator().joinInternal( + joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt), + ) assertThat(result).isInstanceOf(Failure::class.java) } @Test fun `join fails when the location cannot be resolved`() = runTest(testDispatcher) { - coEvery { mockClientImpl.getCachedLocation() } returns + coEvery { clientImpl.getCachedLocation() } returns Failure(Error.GenericError("no location")) - val result = call._join(joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt)) + val result = coordinator().joinInternal( + joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt), + ) assertThat(result).isInstanceOf(Failure::class.java) } @@ -219,63 +229,47 @@ class CallJoinCoordinatorTest { @Test fun `joinAndRing joins then rings the members`() = runTest(testDispatcher) { - coEvery { - call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) - } returns Success(mockJoinResponse) - coEvery { mockSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success - coEvery { call.ring(any()) } returns + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Success + coEvery { apiClient.ring(any()) } returns Success(mockk(relaxed = true)) - val result = call.joinAndRing(members = listOf("u1")) + val result = coordinator().joinAndRing(members = listOf("u1")) advanceUntilIdle() assertThat(result).isInstanceOf(Success::class.java) - coVerify { call.ring(any()) } + coVerify { apiClient.ring(any()) } } @Test fun `joinAndRing leaves the call when ringing fails`() = runTest(testDispatcher) { - coEvery { - call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) - } returns Success(mockJoinResponse) - coEvery { mockSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success - coEvery { call.ring(any()) } returns + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Success + coEvery { apiClient.ring(any()) } returns Failure(Error.GenericError("ring failed")) - every { call.leave(any()) } returns Unit - val result = call.joinAndRing(members = listOf("u1")) + val result = coordinator().joinAndRing(members = listOf("u1")) advanceUntilIdle() assertThat(result).isInstanceOf(Failure::class.java) - coVerify { call.leave(any()) } + coVerify { lifecycle.leave(any()) } } @Test fun `joinRequest delegates to the coordinator client`() = runTest(testDispatcher) { - coEvery { - mockClientImpl.joinCall( - any(), any(), any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any(), - ) - } returns Failure(Error.GenericError("boom")) + stubJoinCall(Failure(Error.GenericError("boom"))) - val result = call.joinRequest( + val result = coordinator().joinRequest( location = "test-location", joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt), ) assertThat(result).isInstanceOf(Failure::class.java) coVerify { - mockClientImpl.joinCall( + clientImpl.joinCall( any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), ) } } - - private fun coordinator(): CallJoinCoordinator { - val field = Call::class.java.getDeclaredField("joinCoordinator") - field.isAccessible = true - return field.get(call) as CallJoinCoordinator - } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt index 190427ed4bc..094997ff883 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt @@ -20,13 +20,11 @@ import android.content.Intent import com.google.common.truth.Truth.assertThat import io.getstream.android.video.generated.models.CallSettingsResponse import io.getstream.android.video.generated.models.OwnCapability -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallState import io.getstream.video.android.core.DeviceStatus import io.getstream.video.android.core.MediaManagerImpl import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.audio.StreamAudioDevice -import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.mockk.every import io.mockk.mockk @@ -36,7 +34,6 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.After import org.junit.Before import org.junit.Test import org.webrtc.audio.JavaAudioDeviceModule.AudioSamples @@ -45,7 +42,7 @@ import org.webrtc.audio.JavaAudioDeviceModule.AudioSamples * Unit tests for [CallMediaManager], which owns the media pipeline: the peer-connection * factory lifecycle, screen sharing, settings-driven device init and audio processing. * - * The [MediaManagerImpl] is injected through [Call.testInstanceProvider] so no real + * The [MediaManagerImpl] is injected through [MediaManagerFactory] so no real * WebRTC / native resources are created. */ class CallMediaManagerTest { @@ -55,24 +52,16 @@ class CallMediaManagerTest { private lateinit var clientImpl: StreamVideoClient private lateinit var state: CallState - private lateinit var sessionFlow: MutableStateFlow + private lateinit var sessionManager: CallSessionManager private lateinit var mediaManager: MediaManagerImpl @Before fun setup() { clientImpl = mockk(relaxed = true) state = mockk(relaxed = true) - sessionFlow = MutableStateFlow(null) + sessionManager = mockk(relaxed = true) mediaManager = mockk(relaxed = true) - - // MediaManagerImpl is provided via testInstanceProvider, so eglBase / callProvider - // are never invoked and no real Call or native resources are needed. - Call.testInstanceProvider.mediaManagerCreator = { mediaManager } - } - - @After - fun tearDown() { - Call.testInstanceProvider.mediaManagerCreator = null + every { sessionManager.session } returns MutableStateFlow(null) } private fun manager() = CallMediaManager( @@ -81,9 +70,9 @@ class CallMediaManagerTest { clientImpl = clientImpl, scope = testScope, state = state, - session = sessionFlow, + sessionManager = sessionManager, eglBase = { mockk(relaxed = true) }, - callProvider = { mockk(relaxed = true) }, + mediaManagerFactory = MediaManagerFactory { _, _ -> mediaManager }, ) @Test diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt index 9ed0ca8a7fc..a1d12c79465 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt @@ -19,11 +19,11 @@ package io.getstream.video.android.core.call.components import com.google.common.truth.Truth.assertThat import io.getstream.android.video.generated.models.JoinCallResponse import io.getstream.result.Result.Success -import io.getstream.video.android.core.Call import io.getstream.video.android.core.CallLeaveReason import io.getstream.video.android.core.CallState import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.analytics.call.CallAnalytics 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 @@ -34,7 +34,6 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before @@ -49,45 +48,61 @@ import stream.video.sfu.models.WebsocketReconnectStrategy class CallReconnectorTest { private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) private lateinit var clientImpl: StreamVideoClient private lateinit var state: CallState private lateinit var connectionFlow: MutableStateFlow - private lateinit var sessionFlow: MutableStateFlow - private lateinit var call: Call + private lateinit var sessionManager: CallSessionManager + private lateinit var sessionMonitor: SessionMonitor + private lateinit var lifecycle: CallLifecycleManager + private lateinit var statsReporter: CallStatsReporter + private lateinit var joinCoordinator: CallJoinCoordinator + private lateinit var callAnalytics: CallAnalytics + private lateinit var sessionFactory: RtcSessionFactory @Before fun setup() { clientImpl = mockk(relaxed = true) state = mockk(relaxed = true) connectionFlow = MutableStateFlow(RealtimeConnection.Reconnecting) - sessionFlow = MutableStateFlow(null) - call = mockk(relaxed = true) + sessionMonitor = mockk(relaxed = true) + lifecycle = mockk(relaxed = true) + statsReporter = mockk(relaxed = true) + joinCoordinator = mockk(relaxed = true) + callAnalytics = mockk(relaxed = true) + sessionFactory = mockk(relaxed = true) + + // A real session manager — it is a plain state holder, so the reconnector's writes + // to the session and the reconnect bookkeeping behave exactly as in production. + sessionManager = CallSessionManager().apply { reconnectDeadlineMillis = 60_000 } every { clientImpl.leaveAfterDisconnectSeconds } returns 120L - every { call.type } returns "default" - every { call.id } returns "call-id" - every { call.clientImpl } returns clientImpl - every { call.scope } returns testScope - every { call.state } returns state - every { call.session } returns sessionFlow - every { call.isDestroyed } returns false + every { lifecycle.isDestroyed } returns false every { clientImpl.coordinatorConnectionModule.networkStateProvider.isConnected() } returns true - every { call.reconnectDeadlineMillis } returns 60_000 - every { call.location } returns null every { state.connection } returns connectionFlow every { state._connection } returns connectionFlow } - private fun reconnector() = CallReconnector(call) + private fun reconnector() = CallReconnector( + clientImpl = clientImpl, + sessionManager = sessionManager, + sessionFactory = sessionFactory, + lifecycle = lifecycle, + sessionMonitor = { sessionMonitor }, + stateProvider = { state }, + callAnalyticsProvider = { callAnalytics }, + statsReporter = { statsReporter }, + joinCoordinator = { joinCoordinator }, + type = "default", + id = "call-id", + ) @Test fun `reconnect is skipped when the call is destroyed`() = runTest(testDispatcher) { - every { call.isDestroyed } returns true + every { lifecycle.isDestroyed } returns true reconnector().reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, @@ -96,7 +111,7 @@ class CallReconnectorTest { advanceUntilIdle() // No leave / failure driven when we bail out immediately. - verify(exactly = 0) { call.leave(any()) } + verify(exactly = 0) { lifecycle.leave(any()) } } @Test @@ -109,7 +124,7 @@ class CallReconnectorTest { ) advanceUntilIdle() - verify(exactly = 0) { call.leave(any()) } + verify(exactly = 0) { lifecycle.leave(any()) } } @Test @@ -120,12 +135,12 @@ class CallReconnectorTest { ) advanceUntilIdle() - verify { call.leave(any()) } + verify { lifecycle.leave(any()) } } @Test fun `rejoin without a location gives up and leaves`() = runTest(testDispatcher) { - every { call.location } returns null + sessionManager.location = null reconnector().reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, @@ -135,12 +150,12 @@ class CallReconnectorTest { assertThat(connectionFlow.value) .isInstanceOf(RealtimeConnection.ReconnectingFailed::class.java) - verify { call.leave(any()) } + verify { lifecycle.leave(any()) } } @Test fun `fast reconnect without a session gives up and leaves`() = runTest(testDispatcher) { - sessionFlow.value = null + sessionManager.setActiveSession(null) reconnector().reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, @@ -183,8 +198,8 @@ class CallReconnectorTest { ) advanceUntilIdle() - assertThat(sessionFlow.value).isSameInstanceAs(newSession) - coVerify { call.monitorSession(joinResponse) } + assertThat(sessionManager.session.value).isSameInstanceAs(newSession) + verify { sessionMonitor.monitorSession(joinResponse) } } @Test @@ -204,7 +219,7 @@ class CallReconnectorTest { ) advanceUntilIdle() - verify { call.leave(any()) } + verify { lifecycle.leave(any()) } } @Test @@ -223,9 +238,9 @@ class CallReconnectorTest { ) advanceUntilIdle() - assertThat(sessionFlow.value).isSameInstanceAs(newSession) + assertThat(sessionManager.session.value).isSameInstanceAs(newSession) coVerify { oldSession.finalizeMigration() } - coVerify { call.monitorSession(joinResponse) } + verify { sessionMonitor.monitorSession(joinResponse) } } /** @@ -237,16 +252,18 @@ class CallReconnectorTest { newSession: RtcSession, joinResponse: JoinCallResponse, ) { - every { call.location } returns "test-location" - sessionFlow.value = oldSession + sessionManager.location = "test-location" + sessionManager.setActiveSession(oldSession) // The old session becomes the new one on every retry, so both need the same stubs. for (s in listOf(oldSession, newSession)) { every { s.currentSfuInfo() } returns Triple("prev-session", emptyList(), emptyList()) every { s.publisher } returns MutableStateFlow(null) } coEvery { - call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + joinCoordinator.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns Success(joinResponse) - every { call.unitTestRtcSessionFactory } returns { newSession } + every { + sessionFactory.create(any(), any(), any(), any(), any(), any(), any()) + } returns newSession } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt index e2eb7ca6f67..3b0b568f90a 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt @@ -35,13 +35,13 @@ import java.util.concurrent.ConcurrentHashMap */ class CallRendererTest { - private val sessionFlow = MutableStateFlow(null) + private val sessionManager = CallSessionManager() private fun renderer() = CallRenderer( type = "default", id = "call-id", scope = mockk(relaxed = true), - session = sessionFlow, + sessionManager = sessionManager, callAnalytics = mockk(relaxed = true), eglBase = { mockk(relaxed = true) }, callSessionId = { "call-id" }, @@ -50,7 +50,7 @@ class CallRendererTest { @Test fun `setVisibility updates track dimensions with default dimension`() { val session = mockk(relaxed = true) - sessionFlow.value = session + sessionManager.setActiveSession(session) renderer().setVisibility("s1", TrackType.TRACK_TYPE_VIDEO, visible = true) @@ -68,7 +68,7 @@ class CallRendererTest { @Test fun `setVisibility with explicit size forwards the requested dimension`() { val session = mockk(relaxed = true) - sessionFlow.value = session + sessionManager.setActiveSession(session) renderer().setVisibility( sessionId = "s1", @@ -85,7 +85,7 @@ class CallRendererTest { @Test fun `setVisibility is a no-op when there is no session`() { - sessionFlow.value = null + sessionManager.setActiveSession(null) // Should not throw. renderer().setVisibility("s1", TrackType.TRACK_TYPE_VIDEO, visible = false) } @@ -93,7 +93,7 @@ class CallRendererTest { @Test fun `setPreferredIncomingVideoResolution forwards overrides`() { val session = mockk(relaxed = true) - sessionFlow.value = session + sessionManager.setActiveSession(session) renderer().setPreferredIncomingVideoResolution( PreferredVideoResolution(width = 1280, height = 720), @@ -111,7 +111,7 @@ class CallRendererTest { @Test fun `setPreferredIncomingVideoResolution clears overrides when resolution is null`() { val session = mockk(relaxed = true) - sessionFlow.value = session + sessionManager.setActiveSession(session) renderer().setPreferredIncomingVideoResolution(null) @@ -126,7 +126,7 @@ class CallRendererTest { @Test fun `setIncomingVideoEnabled forwards visibility overrides`() { val session = mockk(relaxed = true) - sessionFlow.value = session + sessionManager.setActiveSession(session) renderer().setIncomingVideoEnabled(enabled = false, sessionIds = listOf("s1")) @@ -137,7 +137,7 @@ class CallRendererTest { fun `setIncomingAudioEnabled returns early when there is no subscriber`() { val session = mockk(relaxed = true) every { session.subscriber } returns MutableStateFlow(null) - sessionFlow.value = session + sessionManager.setActiveSession(session) // No tracks available -> should return without throwing. renderer().setIncomingAudioEnabled(enabled = true) @@ -146,7 +146,7 @@ class CallRendererTest { @Test fun `setIncomingAudioEnabled toggles audio for all participants`() { val audioTrack = mockk(relaxed = true) - sessionFlow.value = sessionWithAudioTrack(audioTrack) + sessionManager.setActiveSession(sessionWithAudioTrack(audioTrack)) renderer().setIncomingAudioEnabled(enabled = false) @@ -156,7 +156,7 @@ class CallRendererTest { @Test fun `setIncomingAudioEnabled toggles audio for the requested sessions`() { val audioTrack = mockk(relaxed = true) - sessionFlow.value = sessionWithAudioTrack(audioTrack) + sessionManager.setActiveSession(sessionWithAudioTrack(audioTrack)) renderer().setIncomingAudioEnabled(enabled = true, sessionIds = listOf("s1")) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt index e4a0bfa2e73..a40e710b6d8 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt @@ -30,14 +30,17 @@ class CallSessionManagerTest { private fun manager() = CallSessionManager() @Test - fun `session starts empty and can be replaced`() { + fun `session starts empty and can be replaced via setActiveSession`() { val manager = manager() assertThat(manager.session.value).isNull() val session = mockk(relaxed = true) - manager.session.value = session + manager.setActiveSession(session) assertThat(manager.session.value).isSameInstanceAs(session) + + manager.setActiveSession(null) + assertThat(manager.session.value).isNull() } @Test diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt index 5d6934b7899..1ad5e1a335c 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt @@ -19,7 +19,8 @@ package io.getstream.video.android.core.reconnect import io.getstream.video.android.core.Call import io.getstream.video.android.core.base.IntegrationTestBase import io.getstream.video.android.core.call.RtcSession -import io.getstream.video.android.core.internal.network.NetworkStateProvider +import io.getstream.video.android.core.injectMockNetwork +import io.getstream.video.android.core.injectSession import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -35,17 +36,6 @@ import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { - private fun Call.injectMockNetwork(connected: Boolean = true) { - val mockNetwork = mockk(relaxed = true) - every { mockNetwork.isConnected() } returns connected - val monitorField = Call::class.java.getDeclaredField("connectivityMonitor") - monitorField.isAccessible = true - val monitor = monitorField.get(this) - val field = monitor.javaClass.getDeclaredField("network\$delegate") - field.isAccessible = true - field.set(monitor, lazyOf(mockNetwork)) - } - private fun Call.reconnector(): Any { val field = Call::class.java.getDeclaredField("reconnector") field.isAccessible = true @@ -150,7 +140,7 @@ class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { emptyList(), emptyList(), ) - call.session.value = sessionMock + call.injectSession(sessionMock) call.location = "test-location" call.migrate() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt index 48dce5813ca..e94d2ac057f 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt @@ -16,12 +16,12 @@ package io.getstream.video.android.core.reconnect -import io.getstream.video.android.core.Call import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.base.IntegrationTestBase import io.getstream.video.android.core.call.FastReconnectResult import io.getstream.video.android.core.call.RtcSession -import io.getstream.video.android.core.internal.network.NetworkStateProvider +import io.getstream.video.android.core.injectMockNetwork +import io.getstream.video.android.core.injectSession import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -43,17 +43,6 @@ import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) class ReconnectAttemptsCountTest : IntegrationTestBase() { - private fun Call.injectMockNetwork(connected: Boolean = true) { - val mockNetwork = mockk(relaxed = true) - every { mockNetwork.isConnected() } returns connected - val monitorField = Call::class.java.getDeclaredField("connectivityMonitor") - monitorField.isAccessible = true - val monitor = monitorField.get(this) - val field = monitor.javaClass.getDeclaredField("network\$delegate") - field.isAccessible = true - field.set(monitor, lazyOf(mockNetwork)) - } - private fun stubSessionForReconnect(sessionMock: RtcSession) { coEvery { sessionMock.getPublisherStats() } returns null coEvery { sessionMock.getSubscriberStats() } returns null @@ -73,7 +62,7 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { coEvery { sessionMock.fastReconnect(any()) } returns FastReconnectResult.Connected val call = client.call("default", randomUUID()) call.injectMockNetwork(connected = true) - call.session.value = sessionMock + call.injectSession(sessionMock) call.fastReconnect() @@ -95,7 +84,7 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { stubSessionForReconnect(sessionMock) val call = client.call("default", randomUUID()) call.injectMockNetwork(connected = true) - call.session.value = sessionMock + call.injectSession(sessionMock) call.reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, @@ -115,7 +104,7 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { stubSessionForReconnect(sessionMock) val call = client.call("default", randomUUID()) call.injectMockNetwork(connected = true) - call.session.value = sessionMock + call.injectSession(sessionMock) call.reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt index 41b7e59f34f..e96565d5090 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt @@ -20,7 +20,8 @@ import io.getstream.video.android.core.Call import io.getstream.video.android.core.base.IntegrationTestBase import io.getstream.video.android.core.call.FastReconnectResult import io.getstream.video.android.core.call.RtcSession -import io.getstream.video.android.core.internal.network.NetworkStateProvider +import io.getstream.video.android.core.injectMockNetwork +import io.getstream.video.android.core.injectSession import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -44,17 +45,6 @@ private inline fun Call.use(block: (Call) -> R): R { @RunWith(RobolectricTestRunner::class) class ReconnectSessionIdTest : IntegrationTestBase() { - private fun Call.injectMockNetwork(connected: Boolean = true) { - val mockNetwork = mockk(relaxed = true) - every { mockNetwork.isConnected() } returns connected - val monitorField = Call::class.java.getDeclaredField("connectivityMonitor") - monitorField.isAccessible = true - val monitor = monitorField.get(this) - val field = monitor.javaClass.getDeclaredField("network\$delegate") - field.isAccessible = true - field.set(monitor, lazyOf(mockNetwork)) - } - @Test fun `Rejoin creates a new session`() = runTest(UnconfinedTestDispatcher()) { val sessionMock = mockk(relaxed = true) @@ -82,7 +72,7 @@ class ReconnectSessionIdTest : IntegrationTestBase() { coEvery { sessionMock.fastReconnect(any()) } returns FastReconnectResult.Connected val call = client.call("default", randomUUID()) call.injectMockNetwork(connected = true) - call.session.value = sessionMock + call.injectSession(sessionMock) // Fast reconnect call.fastReconnect() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt index 3db4cec4ee0..7da800f0440 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt @@ -17,32 +17,36 @@ package io.getstream.video.android.core.rtc import com.google.common.truth.Truth.assertThat +import io.getstream.android.video.generated.models.CallSettingsResponse import io.getstream.android.video.generated.models.JoinCallResponse import io.getstream.result.Result.Failure import io.getstream.result.Result.Success -import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallState 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.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.base.DispatcherRule import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectFailureCause import io.getstream.video.android.core.call.SfuConnectionResult -import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule -import io.getstream.video.android.core.internal.network.NetworkStateProvider -import io.getstream.video.android.model.User -import io.mockk.MockKAnnotations +import io.getstream.video.android.core.call.components.CallApiClient +import io.getstream.video.android.core.call.components.CallJoinCoordinator +import io.getstream.video.android.core.call.components.CallLifecycleManager +import io.getstream.video.android.core.call.components.CallMediaManager +import io.getstream.video.android.core.call.components.CallReconnector +import io.getstream.video.android.core.call.components.CallSessionManager +import io.getstream.video.android.core.call.components.ClientCallRegistry +import io.getstream.video.android.core.call.components.RtcSessionFactory +import io.getstream.video.android.core.call.components.SessionMonitor import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every -import io.mockk.impl.annotations.RelaxedMockK import io.mockk.mockk -import io.mockk.spyk import io.mockk.unmockkAll -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -54,9 +58,10 @@ import org.junit.Test import stream.video.sfu.models.WebsocketReconnectStrategy /** - * Tests the initial-join handling of failed SFU connect attempts in [Call._join]. + * Tests the initial-join handling of failed SFU connect attempts in + * [CallJoinCoordinator.joinInternal] (reached publicly through `Call._join`). * - * The failure cause decides how `_join` orchestrates recovery: + * The failure cause decides how the join orchestrates recovery: * - [SfuConnectFailureCause.SocketStateObservationTimeout] starts a REJOIN * because no reconnect loop was started by stateJob. * - [SfuConnectFailureCause.RecoverableSocketFailure] waits for the reconnect @@ -71,73 +76,63 @@ class JoinRecoverableFailureTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) - @RelaxedMockK - private lateinit var mockClientImpl: StreamVideoClient - - @RelaxedMockK + private lateinit var clientImpl: StreamVideoClient + private lateinit var state: CallState + private lateinit var sessionManager: CallSessionManager + private lateinit var reconnector: CallReconnector private lateinit var mockSession: RtcSession - - private lateinit var mockStreamVideo: StreamVideo private lateinit var mockJoinResponse: JoinCallResponse - private lateinit var call: Call + private lateinit var connectionFlow: MutableStateFlow @Before fun setup() { - MockKAnnotations.init(this, relaxUnitFun = true) - - mockStreamVideo = mockk(relaxed = true) - StreamVideo.install(mockStreamVideo) - - val mockNetworkStateProvider = mockk(relaxed = true) - every { mockNetworkStateProvider.isConnected() } returns true - val mockCoordinatorModule = mockk(relaxed = true) - every { mockCoordinatorModule.networkStateProvider } returns mockNetworkStateProvider - - every { mockClientImpl.coordinatorConnectionModule } returns mockCoordinatorModule - every { mockClientImpl.scope } returns testScope as CoroutineScope - every { mockClientImpl.leaveAfterDisconnectSeconds } returns 120L - every { mockClientImpl.apiKey } returns "test-api-key" - coEvery { mockClientImpl.getCachedLocation() } returns Success("test-location") - + clientImpl = mockk(relaxed = true) + state = mockk(relaxed = true) + reconnector = mockk(relaxed = true) + mockSession = mockk(relaxed = true) mockJoinResponse = mockk(relaxed = true) - call = spyk( - Call( - client = mockClientImpl, - type = "default", - id = "test-call", - user = User(id = "test-user", role = "user"), - ), - ) - - // The join flow was extracted into CallJoinCoordinator, which captured the real - // Call in Call's constructor. Repoint it at the spy so stubs on call.joinRequest / - // call.reconnect and call.unitTestRtcSessionFactory are visible to the coordinator. - repointJoinCoordinatorToSpy(call) - - // _join builds the JoinCallResponse via joinRequest; stub it out. + sessionManager = CallSessionManager() + connectionFlow = MutableStateFlow(RealtimeConnection.InProgress) + + every { state._connection } returns connectionFlow + every { state.connection } returns connectionFlow + every { state.settings } returns MutableStateFlow(null) + coEvery { clientImpl.getCachedLocation() } returns Success("test-location") coEvery { - call.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + clientImpl.joinCall( + any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), + ) } returns Success(mockJoinResponse) - - // Inject our mocked RtcSession into this Call instead of constructing a real one. - call.unitTestRtcSessionFactory = { mockSession } } @After fun tearDown() { - StreamVideo.removeClient() unmockkAll() } - private fun repointJoinCoordinatorToSpy(spy: Call) { - val coordinatorField = Call::class.java.getDeclaredField("joinCoordinator") - coordinatorField.isAccessible = true - val coordinator = coordinatorField.get(spy) - val callField = coordinator.javaClass.getDeclaredField("call") - callField.isAccessible = true - callField.set(coordinator, spy) - } + private fun coordinator() = CallJoinCoordinator( + clientImpl = clientImpl, + state = state, + callAnalytics = mockk(relaxed = true), + type = "default", + id = "test-call", + scope = testScope, + sessionManager = sessionManager, + sessionFactory = RtcSessionFactory { _, _, _, _, _, _, _ -> mockSession }, + media = mockk(relaxed = true), + lifecycle = mockk(relaxed = true), + apiClient = mockk(relaxed = true), + reconnector = reconnector, + sessionMonitor = mockk(relaxed = true), + callRegistry = mockk(relaxed = true), + hasRequiredPermissions = { true }, + ) + + private suspend fun join() = coordinator().joinInternal( + joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt), + ) @Test fun `recoverable socket failure awaits the existing reconnect loop`() = runTest( @@ -149,17 +144,15 @@ class JoinRecoverableFailureTest { cause = SfuConnectFailureCause.RecoverableSocketFailure, ) - val deferred = async { - call._join(joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt)) - } + val deferred = async { join() } advanceUntilIdle() assertThat(deferred.isCompleted).isFalse() - // stateJob owns the loop here; _join must not start its own. - coVerify(exactly = 0) { call.reconnect(any(), any()) } + // stateJob owns the loop here; the join must not start its own. + coVerify(exactly = 0) { reconnector.reconnect(any(), any()) } // The reconnect loop gives up. - call.state._connection.value = RealtimeConnection.ReconnectingFailed + connectionFlow.value = RealtimeConnection.ReconnectingFailed advanceUntilIdle() assertThat(deferred.await()).isInstanceOf(Failure::class.java) @@ -174,24 +167,20 @@ class JoinRecoverableFailureTest { Exception("SFU connection timed out"), cause = SfuConnectFailureCause.SocketStateObservationTimeout, ) - // Stub the loop so we only assert it is invoked, not run it for real. - coEvery { call.reconnect(any(), any()) } returns Unit - val deferred = async { - call._join(joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt)) - } + val deferred = async { join() } advanceUntilIdle() - // Nothing else would drive recovery, so _join must trigger a REJOIN. + // Nothing else would drive recovery, so the join must trigger a REJOIN. coVerify { - call.reconnect( + reconnector.reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, any(), ) } assertThat(deferred.isCompleted).isFalse() - call.state._connection.value = RealtimeConnection.ReconnectingFailed + connectionFlow.value = RealtimeConnection.ReconnectingFailed advanceUntilIdle() assertThat(deferred.await()).isInstanceOf(Failure::class.java) @@ -207,8 +196,9 @@ class JoinRecoverableFailureTest { cause = SfuConnectFailureCause.TerminalSocketFailure, ) - val result = call._join(joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt)) + val result = join() assertThat(result).isInstanceOf(Failure::class.java) + coVerify(exactly = 0) { reconnector.reconnect(any(), any()) } } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/ReconnectEscalationTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/ReconnectEscalationTest.kt index d7d50fa15ec..92d64363040 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/ReconnectEscalationTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/ReconnectEscalationTest.kt @@ -24,6 +24,7 @@ import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.base.DispatcherRule import io.getstream.video.android.core.call.FastReconnectResult import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.injectSession import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule import io.getstream.video.android.core.internal.network.NetworkStateProvider import io.getstream.video.android.model.User @@ -109,7 +110,7 @@ class ReconnectEscalationTest { emptyList(), ) - call.session.value = mockSession + call.injectSession(mockSession) call.state._connection.value = RealtimeConnection.Connected } From 2adf95e1df14af77ca592ac1414572677d2e2428 Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Mon, 3 Aug 2026 17:51:30 +0530 Subject: [PATCH 08/10] refactor(core): read session state from its owner instead of the Call facade CallSessionManager owns the session identity and reconnect bookkeeping, but Call still re-exposed it through internal accessors that mostly existed for a single caller. Remove them: location and nonFastReconnectAttempts were only reachable from tests, connectStartTime/reconnectStartTime had dead setters, and unifiedSessionId was read by RtcSession alone. RtcSession now takes CallSessionManager directly and reads session identity and reconnect timings from it. The elapsed-time arithmetic moves onto the manager as connectionTimeSeconds()/reconnectionTimeSeconds(), next to the timestamps it derives from. The two test-only reads move into CallTestSeams.kt so they stay out of the production API. Co-authored-by: Cursor --- .../io/getstream/video/android/core/Call.kt | 32 +++---------------- .../video/android/core/call/RtcSession.kt | 12 ++++--- .../call/components/CallSessionManager.kt | 7 ++++ .../video/android/core/CallTestSeams.kt | 28 +++++++++++++++- .../call/components/CallSessionManagerTest.kt | 11 +++++++ .../core/reconnect/FailedSfuIdsTest.kt | 3 +- .../reconnect/ReconnectAttemptsCountTest.kt | 17 +++++----- .../core/rtc/FastReconnectIceRestartTest.kt | 2 ++ .../video/android/core/rtc/RtcSessionTest2.kt | 13 ++++++++ .../core/rtc/SfuConnectionRetryTest.kt | 2 ++ 10 files changed, 84 insertions(+), 43 deletions(-) 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 b1a55f2a1dd..44f93c4dc58 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 @@ -152,33 +152,6 @@ public class Call( set(value) { sessionManager.sessionId = value } - internal val unifiedSessionId: String get() = sessionManager.unifiedSessionId - - internal var location: String? - get() = sessionManager.location - set(value) { - sessionManager.location = value - } - - /** - * Increment this only for REJOIN and MIGRATION strategies - */ - internal var nonFastReconnectAttempts: Int - get() = sessionManager.nonFastReconnectAttempts - set(value) { - sessionManager.nonFastReconnectAttempts = value - } - - internal var connectStartTime: Long - get() = sessionManager.connectStartTime - set(value) { - sessionManager.connectStartTime = value - } - internal var reconnectStartTime: Long - get() = sessionManager.reconnectStartTime - set(value) { - sessionManager.reconnectStartTime = value - } // Unit-test only hook for replacing RtcSession construction. // TODO(v2): replace this with a proper dependency injection boundary. @@ -186,7 +159,9 @@ public class Call( /** * Creates [RtcSession] instances for join / rejoin / migrate. Captures `this` so the - * session's Call dependency never leaks into the join/reconnect orchestrators. + * session's Call dependency never leaks into the join/reconnect orchestrators, and hands it + * the [CallSessionManager] directly so session identity and reconnect timings are read from + * their owner rather than routed back through this facade. */ private val sessionFactory = RtcSessionFactory { sessionId, sessionCounter, sfuUrl, sfuWsUrl, sfuToken, sfuName, iceServers -> @@ -195,6 +170,7 @@ public class Call( sessionCounter = sessionCounter, powerManager = powerManager, call = this, + sessionManager = sessionManager, sessionId = sessionId, apiKey = clientImpl.apiKey, lifecycle = clientImpl.coordinatorConnectionModule.lifecycle, 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 5361ea71915..3e56d97dc31 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 @@ -48,6 +48,7 @@ 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.reporting.model.AnalyticsCallAbortReason +import io.getstream.video.android.core.call.components.CallSessionManager 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 @@ -255,6 +256,7 @@ public class RtcSession internal constructor( private val sessionCounter: Int = 0, private val powerManager: PowerManager?, private val call: Call, + private val sessionManager: CallSessionManager, private val sessionId: String, private val apiKey: String, private val lifecycle: Lifecycle, @@ -1056,7 +1058,7 @@ public class RtcSession internal constructor( ): JoinRequest = JoinRequest( subscriber_sdp = throwawaySubscriberSdpAndOptions(), publisher_sdp = throwawayPublisherSdpAndOptions(), - unified_session_id = call.unifiedSessionId, + unified_session_id = sessionManager.unifiedSessionId, session_id = sessionId, token = sfuToken, fast_reconnect = false, @@ -1071,13 +1073,13 @@ public class RtcSession internal constructor( if (reconnectStrategy == null) { sendCallStats( report = call.collectStats(), - connectionTimeSeconds = (System.currentTimeMillis() - call.connectStartTime) / 1000f, + connectionTimeSeconds = sessionManager.connectionTimeSeconds(), ) } else { sendCallStats( report = call.collectStats(), reconnectionTimeSeconds = Pair( - (System.currentTimeMillis() - call.reconnectStartTime) / 1000f, + sessionManager.reconnectionTimeSeconds(), reconnectStrategy, ), ) @@ -1899,7 +1901,7 @@ public class RtcSession internal constructor( val sendStatsRequest = SendStatsRequest( session_id = sessionId, sdk = "stream-android", - unified_session_id = call.unifiedSessionId, + unified_session_id = sessionManager.unifiedSessionId, sdk_version = BuildConfig.STREAM_VIDEO_VERSION, webrtc_version = BuildConfig.STREAM_WEBRTC_VERSION, publisher_stats = report?.toJson(StreamPeerType.PUBLISHER) ?: "", @@ -1999,7 +2001,7 @@ public class RtcSession internal constructor( subscriber.value?.setTrackDimension(viewportId, sessionId, trackType, visible, dimensions) coroutineScope.launch { serialProcessor.submit("updateTrackDimensions") { - if (sessionId != call.sessionId) { + if (sessionId != sessionManager.sessionId) { // dimension updated for another participant subscriber.value?.setVideoSubscriptions( trackOverridesHandler, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt index 96aa0e67440..41009b3ec6d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt @@ -58,6 +58,13 @@ internal class CallSessionManager() { var connectStartTime = 0L var reconnectStartTime = 0L + /** Seconds elapsed since [connectStartTime], for the connection-time telemetry. */ + fun connectionTimeSeconds(): Float = (System.currentTimeMillis() - connectStartTime) / 1000f + + /** Seconds elapsed since [reconnectStartTime], for the reconnection-time telemetry. */ + fun reconnectionTimeSeconds(): Float = + (System.currentTimeMillis() - reconnectStartTime) / 1000f + /** * Fast-reconnect deadline (in millis), updated at runtime from the SFU's * `fastReconnectDeadlineSeconds`. Written by the session observer and read by the diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt index b196b908a28..81ed6298fd7 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt @@ -33,9 +33,35 @@ import io.mockk.mockk * method that exists purely for tests. */ internal fun Call.injectSession(session: RtcSession?) { + sessionManager().setActiveSession(session) +} + +/** + * Pins the SFU location a call will (re)join, so tests don't depend on a real location lookup. + */ +internal fun Call.injectLocation(location: String?) { + sessionManager().location = location +} + +/** + * Number of REJOIN / MIGRATE attempts the reconnect loop has made, for tests asserting on + * escalation behaviour. Read straight off the owning component: only the reconnect flow has any + * business seeing this counter, so it isn't worth a facade accessor. + */ +internal fun Call.nonFastReconnectAttempts(): Int = sessionManager().nonFastReconnectAttempts + +/** + * Reaches the [CallSessionManager] that owns the session and reconnect bookkeeping. + * + * [Call] intentionally exposes none of this: the session has a single write path through + * [CallSessionManager.setActiveSession], and the rest is bookkeeping shared between the join and + * reconnect flows. Going through reflection keeps those test-only entry points out of the + * production API. + */ +private fun Call.sessionManager(): CallSessionManager { val field = Call::class.java.getDeclaredField("sessionManager") field.isAccessible = true - (field.get(this) as CallSessionManager).setActiveSession(session) + return field.get(this) as CallSessionManager } /** diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt index a40e710b6d8..02d3d43c4f9 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt @@ -89,4 +89,15 @@ class CallSessionManagerTest { assertThat(manager.connectStartTime).isEqualTo(111L) assertThat(manager.reconnectStartTime).isEqualTo(222L) } + + @Test + fun `connection and reconnection times are measured from their start timestamps`() { + val manager = manager() + val fiveSecondsAgo = System.currentTimeMillis() - 5_000L + manager.connectStartTime = fiveSecondsAgo + manager.reconnectStartTime = fiveSecondsAgo - 5_000L + + assertThat(manager.connectionTimeSeconds()).isWithin(1f).of(5f) + assertThat(manager.reconnectionTimeSeconds()).isWithin(1f).of(10f) + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt index 1ad5e1a335c..e59c3d4c7d5 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt @@ -19,6 +19,7 @@ package io.getstream.video.android.core.reconnect import io.getstream.video.android.core.Call import io.getstream.video.android.core.base.IntegrationTestBase import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.injectLocation import io.getstream.video.android.core.injectMockNetwork import io.getstream.video.android.core.injectSession import io.mockk.coEvery @@ -141,7 +142,7 @@ class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { emptyList(), ) call.injectSession(sessionMock) - call.location = "test-location" + call.injectLocation("test-location") call.migrate() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt index e94d2ac057f..2a1ca6e8426 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt @@ -22,6 +22,7 @@ import io.getstream.video.android.core.call.FastReconnectResult import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.injectMockNetwork import io.getstream.video.android.core.injectSession +import io.getstream.video.android.core.nonFastReconnectAttempts import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -35,7 +36,7 @@ import kotlin.test.assertTrue /** * Tests that the unified [io.getstream.video.android.core.Call.reconnect] loop - * increments [io.getstream.video.android.core.Call.nonFastReconnectAttempts] according + * increments the non-fast reconnect attempt counter according * to the JS SDK contract: * - FAST strategy does NOT increment the counter. * - REJOIN strategy increments the counter once per attempt. @@ -71,10 +72,10 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { // REJOIN (because FAST keeps failing), those iterations will increment, // but we verify there's no increment for the initial FAST attempt. assertTrue( - call.nonFastReconnectAttempts == 0 || + call.nonFastReconnectAttempts() == 0 || call.state.connection.value is RealtimeConnection.ReconnectingFailed, "Expected 0 reconnect attempts for FAST or ReconnectingFailed state, " + - "got ${call.nonFastReconnectAttempts}", + "got ${call.nonFastReconnectAttempts()}", ) } @@ -93,8 +94,8 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { // At least one REJOIN attempt should have been counted assertTrue( - call.nonFastReconnectAttempts > 0, - "Expected reconnect attempts > 0, got ${call.nonFastReconnectAttempts}", + call.nonFastReconnectAttempts() > 0, + "Expected reconnect attempts > 0, got ${call.nonFastReconnectAttempts()}", ) } @@ -110,7 +111,7 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, "first", ) - val attemptsAfterFirst = call.nonFastReconnectAttempts + val attemptsAfterFirst = call.nonFastReconnectAttempts() call.reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, @@ -118,8 +119,8 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { ) assertTrue( - call.nonFastReconnectAttempts >= attemptsAfterFirst, - "Expected accumulated attempts >= $attemptsAfterFirst, got ${call.nonFastReconnectAttempts}", + call.nonFastReconnectAttempts() >= attemptsAfterFirst, + "Expected accumulated attempts >= $attemptsAfterFirst, got ${call.nonFastReconnectAttempts()}", ) } } 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 70c3cf0c6e1..67e9f8f1077 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 @@ -31,6 +31,7 @@ import io.getstream.video.android.core.call.FastReconnectResult import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectFailureCause import io.getstream.video.android.core.call.SfuConnectionResult +import io.getstream.video.android.core.call.components.CallSessionManager import io.getstream.video.android.core.call.connection.Publisher import io.getstream.video.android.core.call.connection.Subscriber import io.getstream.video.android.core.internal.module.SfuConnectionModule @@ -130,6 +131,7 @@ class FastReconnectIceRestartTest { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "test-session", apiKey = "test-api-key", lifecycle = mockLifecycle, 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 726082126d7..cfee5ef46e3 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 @@ -31,6 +31,7 @@ import io.getstream.video.android.core.analytics.reporting.model.AnalyticsCallAb import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectFailureCause import io.getstream.video.android.core.call.SfuConnectionResult +import io.getstream.video.android.core.call.components.CallSessionManager import io.getstream.video.android.core.call.connection.Publisher import io.getstream.video.android.core.errors.VideoErrorCode import io.getstream.video.android.core.events.ICETrickleEvent @@ -158,6 +159,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = sessionId, apiKey = apiKey, lifecycle = lifecycle, @@ -211,6 +213,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = sessionId, apiKey = apiKey, lifecycle = mockLifecycle, @@ -270,6 +273,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "test-session-id", apiKey = "test-api-key", lifecycle = mockLifecycle, @@ -329,6 +333,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "test-session-id", apiKey = "test-api-key", lifecycle = mockLifecycle, @@ -400,6 +405,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "test-session-id", apiKey = "test-api-key", lifecycle = mockLifecycle, @@ -458,6 +464,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "test-session-id", apiKey = "test-api-key", lifecycle = mockLifecycle, @@ -506,6 +513,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "test-session-id", apiKey = "test-api-key", lifecycle = mockLifecycle, @@ -561,6 +569,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = sessionId, apiKey = apiKey, lifecycle = mockLifecycle, @@ -622,6 +631,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "session-id", apiKey = "api-key", lifecycle = mockLifecycle, @@ -672,6 +682,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "session-id", apiKey = "api-key", lifecycle = mockLifecycle, @@ -720,6 +731,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = sessionId, apiKey = "test-api-key", lifecycle = mockLifecycle, @@ -818,6 +830,7 @@ class RtcSessionTest2 { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "session-id", apiKey = "api-key", lifecycle = mockLifecycle, 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 f894103b86b..6c5cc7441b1 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 @@ -29,6 +29,7 @@ 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.call.components.CallSessionManager import io.getstream.video.android.core.events.JoinCallResponseEvent import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule import io.getstream.video.android.core.internal.module.SfuConnectionModule @@ -149,6 +150,7 @@ class SfuConnectionRetryTest { client = mockStreamVideo, powerManager = mockPowerManager, call = mockCall, + sessionManager = CallSessionManager(), sessionId = "test-session-id", apiKey = "test-api-key", lifecycle = mockLifecycle, From 543439f29327461b322ba2f0c1e516958d80238f Mon Sep 17 00:00:00 2001 From: pratimmallick Date: Mon, 3 Aug 2026 19:36:25 +0530 Subject: [PATCH 09/10] refactor(core): break the reconnector/join-coordinator dependency cycle CallReconnector and CallJoinCoordinator each depended on the other, so one had to be injected as a lazy provider. Move the shared joinRequest into CallApiClient, which already owns the coordinator REST calls, and relocate the failed-SFU set to CallSessionManager so the request no longer has to ask the reconnector for it. Both orchestrators now depend on the api client and neither depends on the other. Also drops the provider lambdas around state, analytics, stats and media by declaring those components before their consumers. FailedSfuIdsTest no longer needs reflection into private reconnector members; the behaviour is covered directly in CallSessionManagerTest. Co-authored-by: Cursor --- .../io/getstream/video/android/core/Call.kt | 246 ++++++++++-------- .../core/call/components/CallApiClient.kt | 48 ++++ .../core/call/components/CallEventManager.kt | 6 +- .../call/components/CallJoinCoordinator.kt | 41 +-- .../call/components/CallLifecycleManager.kt | 25 +- .../core/call/components/CallReconnector.kt | 41 +-- .../call/components/CallSessionManager.kt | 24 ++ .../video/android/core/CallTestSeams.kt | 6 + .../core/call/components/CallApiClientTest.kt | 29 +++ .../call/components/CallEventManagerTest.kt | 4 +- .../components/CallJoinCoordinatorTest.kt | 37 +-- .../call/components/CallReconnectorTest.kt | 22 +- .../call/components/CallSessionManagerTest.kt | 29 +++ .../core/reconnect/FailedSfuIdsTest.kt | 86 +++--- .../core/rtc/JoinRecoverableFailureTest.kt | 9 +- 15 files changed, 355 insertions(+), 298 deletions(-) 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 44f93c4dc58..65951028d49 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 @@ -144,7 +144,17 @@ public class Call( /** Delegate that owns the live RTC session state and reconnect bookkeeping. */ private val sessionManager = CallSessionManager() - /** Session handles all real time communication for video and audio */ + /** + * Session handles all real time communication for video and audio. + * + * This is the read path for collaborators outside the decomposition — `CallStats`, + * `StreamVideoClient`, `ActiveStateGate`, the media session controller and [Debug]. Components + * under `call.components` take [CallSessionManager] directly rather than reading it from here. + */ + // TODO(v2): hand those consumers the CallSessionManager and drop this accessor. Blocked on + // binary compatibility today: CallStats' constructor is published ABI, and adding a parameter + // replaces it rather than overloading it (defaults are source-level only). CallSessionManager + // is also internal, so it cannot appear in a public signature at all. internal val session: StateFlow get() = sessionManager.session var sessionId: String @@ -218,6 +228,96 @@ public class Call( } } + // --------------------------------------------------------------------------------------- + // Component graph. Declaration order is load-bearing: Kotlin initialises properties top to + // bottom, so a component can only take a collaborator directly if that collaborator is + // declared above it. + // + // A `() -> T` parameter below is never an ordering accident; it is one of three things: + // - a cycle: two components need each other, so the later one is injected as a provider + // - a deferral: `eglBase` stays lazy so the native EGL context is only created if used + // - a live read: mutable state such as `reconnectDeadlineMillis`, where the component needs + // the current value rather than a snapshot taken at construction + // --------------------------------------------------------------------------------------- + + /** + * EGL base context shared between peerConnectionFactory and mediaManager + * to break circular dependency. + */ + internal val eglBase: EglBase by lazy { + EglBase.create() + } + + /** Delegate that restarts ICE when the publisher/subscriber connections drop. */ + private val iceMonitor: CallIceConnectionMonitor = + CallIceConnectionMonitor(type, id, scope, sessionManager) + + /** Delegate that owns the event flow, subscriptions and event dispatch. */ + private val eventManager = CallEventManager(type, id, scope, reconnector = { reconnector }) + + // Must be initialized before `state` — CallState → SortedParticipantsState + // launches a coroutine that reads `call.events` (leaking-this race). + val events: MutableSharedFlow = eventManager.events + + /** The call state contains all state such as the participant list, reactions etc */ + val state = CallState(client, this, user, scope) + + internal val callAnalytics = + CallAnalytics( + clientImpl.context, + this.id, + this.type, + state.me, + state.connection, + state.participants, + client.state.clientEventReporter, + scope, + ) + + /** Delegate that periodically collects and reports WebRTC stats. */ + private val statsReporter = CallStatsReporter(type, id, scope, sessionManager, state) + + /** Delegate that wraps all coordinator (REST) API calls for this call. */ + private val apiClient = CallApiClient( + type = type, + id = id, + state = state, + clientImpl = clientImpl, + scope = scope, + callSessionId = { sessionId }, + callRegistry = callRegistry, + callAnalytics = callAnalytics, + sessionManager = sessionManager, + ) + + /** + * Creates [MediaManagerImpl] for this call. Captures `this` (and the test hook) so + * [CallMediaManager] never needs a Call reference. + */ + private val mediaManagerFactory = MediaManagerFactory { audioUsage, audioUsageProvider -> + testInstanceProvider.mediaManagerCreator?.invoke() + ?: MediaManagerImpl( + clientImpl.context, + this, + scope, + eglBase.eglBaseContext, + audioUsage, + audioUsageProvider, + ) + } + + /** Delegate that owns the peer-connection factory, media manager and audio pipeline. */ + private val media = CallMediaManager( + type = type, + id = id, + clientImpl = clientImpl, + scope = scope, + state = state, + sessionManager = sessionManager, + eglBase = { eglBase }, + mediaManagerFactory = mediaManagerFactory, + ) + /** Delegate that owns leave / end / cleanup teardown and the destroyed flag. */ private val lifecycle: CallLifecycleManager = CallLifecycleManager( clientImpl = clientImpl, @@ -232,12 +332,12 @@ public class Call( } scope.cancel() }, - stateProvider = { state }, - callAnalyticsProvider = { callAnalytics }, - statsReporter = { statsReporter }, - media = { media }, + state = state, + callAnalytics = callAnalytics, + statsReporter = statsReporter, + media = media, + iceMonitor = iceMonitor, sessionMonitor = { sessionMonitor }, - iceMonitor = { iceMonitor }, connectivityMonitor = { connectivityMonitor }, type = type, id = id, @@ -249,24 +349,39 @@ public class Call( sessionManager = sessionManager, sessionFactory = sessionFactory, lifecycle = lifecycle, + apiClient = apiClient, + state = state, + callAnalytics = callAnalytics, + statsReporter = statsReporter, sessionMonitor = { sessionMonitor }, - stateProvider = { state }, - callAnalyticsProvider = { callAnalytics }, - statsReporter = { statsReporter }, - joinCoordinator = { joinCoordinator }, type = type, id = id, ) - /** Delegate that owns the event flow, subscriptions and event dispatch. */ - private val eventManager = CallEventManager(type, id, scope, reconnector) - - // Must be initialized before `state` — CallState → SortedParticipantsState - // launches a coroutine that reads `call.events` (leaking-this race). - val events: MutableSharedFlow = eventManager.events + /** Delegate that reacts to device connectivity changes (reconnect / leave-on-timeout). */ + private val connectivityMonitor: CallConnectivityMonitor = CallConnectivityMonitor( + type = type, + id = id, + clientImpl = clientImpl, + scope = scope, + state = state, + reconnector = reconnector, + lifecycle = lifecycle, + reconnectDeadlineMillis = { reconnectDeadlineMillis }, + ) - /** The call state contains all state such as the participant list, reactions etc */ - val state = CallState(client, this, user, scope) + /** Delegate that owns the SFU signal/event observers and (re)wires per-session monitoring. */ + private val sessionMonitor: SessionMonitor = SessionMonitor( + type = type, + id = id, + scope = scope, + state = state, + sessionManager = sessionManager, + statsReporter = statsReporter, + iceMonitor = iceMonitor, + connectivityMonitor = connectivityMonitor, + callAnalytics = callAnalytics, + ) /** Camera gives you access to the local camera */ val camera get() = mediaManager.camera @@ -315,46 +430,12 @@ public class Call( */ internal val isDestroyed: Boolean get() = lifecycle.isDestroyed - /** - * EGL base context shared between peerConnectionFactory and mediaManager - * to break circular dependency. - */ - internal val eglBase: EglBase by lazy { - EglBase.create() - } - internal var peerConnectionFactory: StreamPeerConnectionFactory get() = media.peerConnectionFactory set(value) { media.peerConnectionFactory = value } - internal val callAnalytics = - CallAnalytics( - clientImpl.context, - this.id, - this.type, - state.me, - state.connection, - state.participants, - client.state.clientEventReporter, - scope, - ) - - /** Delegate that wraps all coordinator (REST) API calls for this call. */ - private val apiClient = CallApiClient( - type = type, - id = id, - state = state, - clientImpl = clientImpl, - scope = scope, - callSessionId = { sessionId }, - callRegistry = callRegistry, - ) - - /** Delegate that periodically collects and reports WebRTC stats. */ - private val statsReporter = CallStatsReporter(type, id, scope, sessionManager, state) - /** Delegate that binds video tracks to renderers and handles media-quality overrides. */ private val callRenderer = CallRenderer( type = type, @@ -366,34 +447,6 @@ public class Call( callSessionId = { sessionId }, ) - /** - * Creates [MediaManagerImpl] for this call. Captures `this` (and the test hook) so - * [CallMediaManager] never needs a Call reference. - */ - private val mediaManagerFactory = MediaManagerFactory { audioUsage, audioUsageProvider -> - testInstanceProvider.mediaManagerCreator?.invoke() - ?: MediaManagerImpl( - clientImpl.context, - this, - scope, - eglBase.eglBaseContext, - audioUsage, - audioUsageProvider, - ) - } - - /** Delegate that owns the peer-connection factory, media manager and audio pipeline. */ - private val media = CallMediaManager( - type = type, - id = id, - clientImpl = clientImpl, - scope = scope, - state = state, - sessionManager = sessionManager, - eglBase = { eglBase }, - mediaManagerFactory = mediaManagerFactory, - ) - /** * Checks if the audioBitrateProfile has changed since the factory was created, * and recreates the factory if needed. This should only be called before joining. @@ -412,35 +465,6 @@ public class Call( internal val mediaManager get() = media.mediaManager - /** Delegate that reacts to device connectivity changes (reconnect / leave-on-timeout). */ - private val connectivityMonitor: CallConnectivityMonitor = CallConnectivityMonitor( - type = type, - id = id, - clientImpl = clientImpl, - scope = scope, - state = state, - reconnector = reconnector, - lifecycle = lifecycle, - reconnectDeadlineMillis = { reconnectDeadlineMillis }, - ) - - /** Delegate that restarts ICE when the publisher/subscriber connections drop. */ - private val iceMonitor: CallIceConnectionMonitor = - CallIceConnectionMonitor(type, id, scope, sessionManager) - - /** Delegate that owns the SFU signal/event observers and (re)wires per-session monitoring. */ - private val sessionMonitor: SessionMonitor = SessionMonitor( - type = type, - id = id, - scope = scope, - state = state, - sessionManager = sessionManager, - statsReporter = statsReporter, - iceMonitor = iceMonitor, - connectivityMonitor = connectivityMonitor, - callAnalytics = callAnalytics, - ) - /** Delegate that drives the join flow (permissions, retry loop, session creation). */ private val joinCoordinator = CallJoinCoordinator( clientImpl = clientImpl, @@ -801,7 +825,7 @@ public class Call( * Clears the failed SFU list so we don't exclude this SFU on future requests. */ internal fun onSfuConnectionEstablished() { - reconnector.clearFailedSfuIds() + sessionManager.clearFailedSfuIds() } @VisibleForTesting @@ -814,7 +838,7 @@ public class Call( notify: Boolean = false, hintHighScaleLivestreamPublisher: Boolean? = null, joinAnalyticsModel: JoinAnalyticsModel, - ): Result = joinCoordinator.joinRequest( + ): Result = apiClient.joinRequest( create, location, migratingFrom, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt index f021067953a..971d05a47bd 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt @@ -22,6 +22,7 @@ import io.getstream.android.video.generated.models.CallSettingsRequest import io.getstream.android.video.generated.models.GetCallResponse import io.getstream.android.video.generated.models.GetOrCreateCallResponse import io.getstream.android.video.generated.models.GoLiveResponse +import io.getstream.android.video.generated.models.JoinCallResponse import io.getstream.android.video.generated.models.KickUserResponse import io.getstream.android.video.generated.models.ListRecordingsResponse import io.getstream.android.video.generated.models.ListTranscriptionsResponse @@ -47,7 +48,10 @@ import io.getstream.android.video.generated.models.UpdateUserPermissionsResponse import io.getstream.log.taggedLogger import io.getstream.result.Result import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.CreateCallOptions import io.getstream.video.android.core.StreamVideoClient +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.model.MuteUsersData import io.getstream.video.android.core.model.QueriedMembers import io.getstream.video.android.core.model.RejectReason @@ -74,9 +78,53 @@ internal class CallApiClient( private val scope: CoroutineScope, private val callSessionId: () -> String, private val callRegistry: ClientCallRegistry, + private val callAnalytics: CallAnalytics, + private val sessionManager: CallSessionManager, ) { private val logger by taggedLogger("Call:ApiClient:$type:$id") + /** + * Executes the coordinator join request. Shared by the join flow and the reconnect flow + * (rejoin / migrate), which is why it lives here rather than in either of them. + */ + suspend fun joinRequest( + create: CreateCallOptions? = null, + location: String, + migratingFrom: String? = null, + migratingFromList: List? = null, + ring: Boolean = false, + notify: Boolean = false, + hintHighScaleLivestreamPublisher: Boolean? = null, + joinAnalyticsModel: JoinAnalyticsModel, + ): Result { + val migratingFromList = + migratingFromList ?: sessionManager.failedSfuIdsSnapshot().takeIf { it.isNotEmpty() } + callAnalytics.joinAnalytics.onJoinRequestStart(joinAnalyticsModel.joinReason) + val result = clientImpl.joinCall( + type, id, + create = create != null, + members = create?.memberRequestsFromIds(), + custom = create?.custom, + settingsOverride = create?.settings, + startsAt = create?.startsAt, + team = create?.team, + ring = ring, + notify = notify, + location = location, + migratingFrom = migratingFrom, + migratingFromList = migratingFromList, + hintHighScaleLivestreamPublisher = hintHighScaleLivestreamPublisher, + ) + result.onSuccess { + callAnalytics.joinAnalytics.onJoinRequestSuccess( + joinAnalyticsModel, + it.call.currentSessionId, + ) + state.updateFromResponse(it) + } + return result + } + suspend fun get(): Result { val response = clientImpl.getCall(type, id) response.onSuccess { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt index 5c3732d93ce..d70062057c0 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt @@ -34,7 +34,9 @@ internal class CallEventManager( private val type: String, private val id: String, private val scope: CoroutineScope, - private val reconnector: CallReconnector, + // Lazy provider: the reconnector is built after the event pipeline, because `CallState` reads + // `events` while constructing and the reconnector transitively needs the state. + private val reconnector: () -> CallReconnector, ) { private val logger by taggedLogger("Call:EventManager:$type:$id") @@ -72,7 +74,7 @@ internal class CallEventManager( when (event) { is GoAwayEvent -> scope.launch { - reconnector.migrate() + reconnector().migrate() } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt index 94952d034d2..4ea7451beb3 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -16,7 +16,6 @@ package io.getstream.video.android.core.call.components -import io.getstream.android.video.generated.models.JoinCallResponse import io.getstream.android.video.generated.models.RingCallRequest import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -237,7 +236,7 @@ internal class CallJoinCoordinator( null } val result = - joinRequest( + apiClient.joinRequest( options, locationResult.value, ring = ring, @@ -367,42 +366,4 @@ internal class CallJoinCoordinator( logger.d { "[_join] Reconnect after recoverable connection failure settled on $terminal" } return terminal is RealtimeConnection.Connected } - - suspend fun joinRequest( - create: CreateCallOptions? = null, - location: String, - migratingFrom: String? = null, - migratingFromList: List? = null, - ring: Boolean = false, - notify: Boolean = false, - hintHighScaleLivestreamPublisher: Boolean? = null, - joinAnalyticsModel: JoinAnalyticsModel, - ): Result { - val migratingFromList = - migratingFromList ?: reconnector.getFailedSfuIdsSnapshot().takeIf { it.isNotEmpty() } - callAnalytics.joinAnalytics.onJoinRequestStart(joinAnalyticsModel.joinReason) - val result = clientImpl.joinCall( - type, id, - create = create != null, - members = create?.memberRequestsFromIds(), - custom = create?.custom, - settingsOverride = create?.settings, - startsAt = create?.startsAt, - team = create?.team, - ring = ring, - notify = notify, - location = location, - migratingFrom = migratingFrom, - migratingFromList = migratingFromList, - 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/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt index 8636cf3ea13..ba1a4174e8f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -45,13 +45,14 @@ internal class CallLifecycleManager( private val callRegistry: ClientCallRegistry, /** Cancels the call's supervisor job and scope once in-flight children complete. */ private val shutDownJobs: () -> Unit, - // Lazy providers: constructed after this component during Call initialization. - private val stateProvider: () -> CallState, - private val callAnalyticsProvider: () -> CallAnalytics, - private val statsReporter: () -> CallStatsReporter, - private val media: () -> CallMediaManager, + private val state: CallState, + private val callAnalytics: CallAnalytics, + private val statsReporter: CallStatsReporter, + private val media: CallMediaManager, + private val iceMonitor: CallIceConnectionMonitor, + // Lazy providers: both sit on a cycle with this component — the connectivity monitor takes the + // lifecycle manager to leave on timeout, and the session monitor is built from that monitor. private val sessionMonitor: () -> SessionMonitor, - private val iceMonitor: () -> CallIceConnectionMonitor, private val connectivityMonitor: () -> CallConnectivityMonitor, private val type: String, private val id: String, @@ -59,8 +60,6 @@ internal class CallLifecycleManager( private val logger by taggedLogger("Call:LifecycleManager:$type:$id") private val cid = "$type:$id" - private val state get() = stateProvider() - private val callAnalytics get() = callAnalyticsProvider() // Atomic controls private var atomicLeave = AtomicUnitCall() @@ -106,7 +105,7 @@ internal class CallLifecycleManager( /** * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) */ - media().disableLocalCapture() + media.disableLocalCapture() callRegistry.detach() @@ -115,7 +114,7 @@ internal class CallLifecycleManager( callAnalytics.onCallLeave(sessionManager.session, reason) safeCall { sessionManager.session.value?.sfuTracer?.trace("leave-call", leaveReason) - val stats = statsReporter().collectStats() + val stats = statsReporter.collectStats() sessionManager.session.value?.sendCallStats(stats) } // Must complete before cleanup() cancels the session's supervisor job. @@ -143,8 +142,8 @@ internal class CallLifecycleManager( state.cleanup() sessionManager.session.value?.cleanup() shutDownJobs() - statsReporter().stop() - media().cleanup() // TODO Rahul, Verify Later: need to check which call has owned the media at the moment(probably use active call) + statsReporter.stop() + media.cleanup() // TODO Rahul, Verify Later: need to check which call has owned the media at the moment(probably use active call) sessionManager.setActiveSession(null) // Cleanup the call's scope provider scopeProvider.cleanup() @@ -152,7 +151,7 @@ internal class CallLifecycleManager( /** Stops the ICE and connectivity monitors. */ private fun stopConnectionMonitors() { - iceMonitor().stop() + iceMonitor.stop() connectivityMonitor().cancelLeaveTimeout() connectivityMonitor().unsubscribe() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt index 34606aa109b..88c72d3dd37 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt @@ -35,7 +35,6 @@ import kotlinx.coroutines.sync.Mutex import stream.video.sfu.event.ReconnectDetails import stream.video.sfu.models.WebsocketReconnectStrategy import java.util.UUID -import java.util.concurrent.ConcurrentHashMap /** * Outcome of a single reconnect attempt. Each reconnect method returns one of @@ -73,21 +72,18 @@ internal class CallReconnector( private val sessionManager: CallSessionManager, private val sessionFactory: RtcSessionFactory, private val lifecycle: CallLifecycleManager, - // Lazy providers: constructed after the reconnector in Call (avoids construction cycles / - // init-order issues). + private val apiClient: CallApiClient, + private val state: CallState, + private val callAnalytics: CallAnalytics, + private val statsReporter: CallStatsReporter, + // Lazy provider: the session monitor is built from the connectivity monitor, which takes + // this component, so it can only be resolved after construction. private val sessionMonitor: () -> SessionMonitor, - private val stateProvider: () -> CallState, - private val callAnalyticsProvider: () -> CallAnalytics, - private val statsReporter: () -> CallStatsReporter, - private val joinCoordinator: () -> CallJoinCoordinator, private val type: String, private val id: String, ) { private val logger by taggedLogger("Call:Reconnector:$type:$id") - private val state get() = stateProvider() - private val callAnalytics get() = callAnalyticsProvider() - // Read connectivity from the leaf NetworkStateProvider directly rather than routing // through CallConnectivityMonitor — that back-reference would form a dependency cycle // (ConnectivityMonitor → Reconnector → ConnectivityMonitor). @@ -99,7 +95,6 @@ internal class CallReconnector( * SFU IDs (edge names) we failed to connect to (e.g. SFU_FULL). Sent in migrating_from_list * when requesting new credentials so the coordinator can exclude them. */ - private val failedSfuIds: MutableSet = ConcurrentHashMap.newKeySet() /** * Unified reconnection entry point. @@ -336,7 +331,7 @@ internal class CallReconnector( val currentSession = sessionManager.session.value ?: return ReconnectOutcome.PreconditionNotMet("No active session for fast reconnect") - val stats = statsReporter().collectStats() + val stats = statsReporter.collectStats() currentSession.sendCallStats(stats) currentSession.prepareReconnect() @@ -376,7 +371,7 @@ internal class CallReconnector( ?: return ReconnectOutcome.PreconditionNotMet("No active session for rejoin") sessionManager.reconnectStartTime = System.currentTimeMillis() - val joinResponse = joinCoordinator().joinRequest( + val joinResponse = apiClient.joinRequest( location = loc, joinAnalyticsModel = joinAnalyticsModel, ) @@ -440,10 +435,10 @@ internal class CallReconnector( val oldSession = sessionManager.session.value ?: return ReconnectOutcome.PreconditionNotMet("No active session for migrate") sessionManager.reconnectStartTime = System.currentTimeMillis() - addFailedSfuId(oldSession.sfuName) + sessionManager.addFailedSfuId(oldSession.sfuName) val joinResponse = - joinCoordinator().joinRequest( + apiClient.joinRequest( location = loc, migratingFrom = oldSession.sfuName, joinAnalyticsModel = joinAnalyticsModel, @@ -471,7 +466,7 @@ internal class CallReconnector( reconnect_attempt = sessionManager.nonFastReconnectAttempts, ) - val stats = statsReporter().collectStats() + val stats = statsReporter.collectStats() oldSession.sendCallStats(stats) oldSession.enterMigration() @@ -515,20 +510,6 @@ internal class CallReconnector( reconnect(WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE, "migrate") } - /** Adds the given SFU ID (edge name) to the failed set (for migrating_from_list). */ - private fun addFailedSfuId(sfuId: String) { - if (sfuId.isBlank()) return - failedSfuIds.add(sfuId) - } - - /** Returns a snapshot of failed SFU IDs to send as migrating_from_list. */ - fun getFailedSfuIdsSnapshot(): List = failedSfuIds.toList() - - /** Clears the failed SFU list (e.g. after a successful join). */ - fun clearFailedSfuIds() { - failedSfuIds.clear() - } - companion object { /** How many consecutive FAST reconnect failures are allowed before * escalating to a full REJOIN. Kept small because each failed FAST diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt index 41009b3ec6d..472ee2a227b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.util.UUID +import java.util.concurrent.ConcurrentHashMap /** * Owns the live RTC session state for a call and the bookkeeping shared across the @@ -65,6 +66,29 @@ internal class CallSessionManager() { fun reconnectionTimeSeconds(): Float = (System.currentTimeMillis() - reconnectStartTime) / 1000f + /** + * SFU IDs (edge names) that have already failed for this call, sent as `migrating_from_list` + * so the coordinator can steer the next join away from them. + * + * Written by the reconnect flow and read when building the join request. It lives here rather + * than in either of those components so neither has to depend on the other. + */ + private val failedSfuIds: MutableSet = ConcurrentHashMap.newKeySet() + + /** Adds the given SFU ID to the failed set. Blank IDs are ignored. */ + fun addFailedSfuId(sfuId: String) { + if (sfuId.isBlank()) return + failedSfuIds.add(sfuId) + } + + /** Returns a snapshot of failed SFU IDs to send as `migrating_from_list`. */ + fun failedSfuIdsSnapshot(): List = failedSfuIds.toList() + + /** Clears the failed SFU list (e.g. after a successful join). */ + fun clearFailedSfuIds() { + failedSfuIds.clear() + } + /** * Fast-reconnect deadline (in millis), updated at runtime from the SFU's * `fastReconnectDeadlineSeconds`. Written by the session observer and read by the diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt index 81ed6298fd7..337718bf3ea 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt @@ -36,6 +36,12 @@ internal fun Call.injectSession(session: RtcSession?) { sessionManager().setActiveSession(session) } +/** Records [sfuId] as a failed edge for this call, as the reconnect flow does on migrate. */ +internal fun Call.addFailedSfuId(sfuId: String) = sessionManager().addFailedSfuId(sfuId) + +/** Snapshot of the SFU IDs this call has recorded as failed. */ +internal fun Call.failedSfuIds(): List = sessionManager().failedSfuIdsSnapshot() + /** * Pins the SFU location a call will (re)join, so tests don't depend on a real location lookup. */ diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt index a13aca66445..79197027a93 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt @@ -28,6 +28,8 @@ import io.getstream.android.video.generated.models.UpdateCallResponse import io.getstream.result.Result import io.getstream.video.android.core.CallState import io.getstream.video.android.core.StreamVideoClient +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.model.RejectReason import io.getstream.video.android.core.model.SortField import io.getstream.video.android.core.recording.RecordingType @@ -55,6 +57,7 @@ class CallApiClientTest { private lateinit var clientImpl: StreamVideoClient private lateinit var state: CallState private lateinit var callRegistry: ClientCallRegistry + private lateinit var sessionManager: CallSessionManager private lateinit var apiClient: CallApiClient @Before @@ -62,6 +65,7 @@ class CallApiClientTest { clientImpl = mockk(relaxed = true) state = mockk(relaxed = true) callRegistry = mockk(relaxed = true) + sessionManager = CallSessionManager() // Response-processing endpoints return Success so the onSuccess/state-update // branches are exercised. @@ -95,6 +99,8 @@ class CallApiClientTest { scope = testScope, callSessionId = { "session-id" }, callRegistry = callRegistry, + callAnalytics = mockk(relaxed = true), + sessionManager = sessionManager, ) } @@ -323,4 +329,27 @@ class CallApiClientTest { ) } } + + @Test + fun `joinRequest delegates to the coordinator client`() = runTest(testDispatcher) { + coEvery { + clientImpl.joinCall( + any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), + ) + } returns Result.Failure(io.getstream.result.Error.GenericError("boom")) + + val result = apiClient.joinRequest( + location = "test-location", + joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt), + ) + + assertThat(result).isInstanceOf(Result.Failure::class.java) + coVerify { + clientImpl.joinCall( + any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), + ) + } + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt index 830f507e063..6897a4dd58a 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt @@ -47,7 +47,9 @@ class CallEventManagerTest { private val reconnector = mockk(relaxed = true) - private fun manager() = CallEventManager("default", "call-id", testScope, reconnector) + private fun manager() = CallEventManager("default", "call-id", testScope, reconnector = { + reconnector + }) @Test fun `subscribe without filter receives every fired event`() { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt index f545cc1adfd..b1fec5161ac 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -133,9 +133,8 @@ class CallJoinCoordinatorTest { private fun stubJoinCall(result: io.getstream.result.Result) { coEvery { - clientImpl.joinCall( - any(), any(), any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any(), + apiClient.joinRequest( + any(), any(), any(), any(), any(), any(), any(), any(), ) } returns result } @@ -183,11 +182,17 @@ class CallJoinCoordinatorTest { advanceUntilIdle() assertThat(result).isInstanceOf(Failure::class.java) - // joinRequest -> clientImpl.joinCall is attempted once per retry (3 attempts total). + // The join request is issued once per retry (3 attempts total). coVerify(exactly = 3) { - clientImpl.joinCall( - any(), any(), any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any(), + apiClient.joinRequest( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), ) } } @@ -254,22 +259,4 @@ class CallJoinCoordinatorTest { assertThat(result).isInstanceOf(Failure::class.java) coVerify { lifecycle.leave(any()) } } - - @Test - fun `joinRequest delegates to the coordinator client`() = runTest(testDispatcher) { - stubJoinCall(Failure(Error.GenericError("boom"))) - - val result = coordinator().joinRequest( - location = "test-location", - joinAnalyticsModel = JoinAnalyticsModel(0, JoinReason.FirstAttempt), - ) - - assertThat(result).isInstanceOf(Failure::class.java) - coVerify { - clientImpl.joinCall( - any(), any(), any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any(), - ) - } - } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt index a1d12c79465..90d008d4c5f 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt @@ -56,7 +56,7 @@ class CallReconnectorTest { private lateinit var sessionMonitor: SessionMonitor private lateinit var lifecycle: CallLifecycleManager private lateinit var statsReporter: CallStatsReporter - private lateinit var joinCoordinator: CallJoinCoordinator + private lateinit var apiClient: CallApiClient private lateinit var callAnalytics: CallAnalytics private lateinit var sessionFactory: RtcSessionFactory @@ -68,7 +68,7 @@ class CallReconnectorTest { sessionMonitor = mockk(relaxed = true) lifecycle = mockk(relaxed = true) statsReporter = mockk(relaxed = true) - joinCoordinator = mockk(relaxed = true) + apiClient = mockk(relaxed = true) callAnalytics = mockk(relaxed = true) sessionFactory = mockk(relaxed = true) @@ -91,11 +91,11 @@ class CallReconnectorTest { sessionManager = sessionManager, sessionFactory = sessionFactory, lifecycle = lifecycle, + apiClient = apiClient, + state = state, + callAnalytics = callAnalytics, + statsReporter = statsReporter, sessionMonitor = { sessionMonitor }, - stateProvider = { state }, - callAnalyticsProvider = { callAnalytics }, - statsReporter = { statsReporter }, - joinCoordinator = { joinCoordinator }, type = "default", id = "call-id", ) @@ -167,14 +167,6 @@ class CallReconnectorTest { .isInstanceOf(RealtimeConnection.ReconnectingFailed::class.java) } - @Test - fun `failed sfu id bookkeeping is exposed as a snapshot`() { - val reconnector = reconnector() - assertThat(reconnector.getFailedSfuIdsSnapshot()).isEmpty() - reconnector.clearFailedSfuIds() - assertThat(reconnector.getFailedSfuIdsSnapshot()).isEmpty() - } - @Test fun `strategy helpers forward to reconnect without throwing`() = runTest(testDispatcher) { val reconnector = reconnector() @@ -260,7 +252,7 @@ class CallReconnectorTest { every { s.publisher } returns MutableStateFlow(null) } coEvery { - joinCoordinator.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + apiClient.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) } returns Success(joinResponse) every { sessionFactory.create(any(), any(), any(), any(), any(), any(), any()) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt index 02d3d43c4f9..23be4570bd3 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt @@ -90,6 +90,35 @@ class CallSessionManagerTest { assertThat(manager.reconnectStartTime).isEqualTo(222L) } + @Test + fun `failed sfu ids are de-duplicated, ignore blanks, and clear`() { + val manager = manager() + assertThat(manager.failedSfuIdsSnapshot()).isEmpty() + + manager.addFailedSfuId("sfu-edge-1") + manager.addFailedSfuId("sfu-edge-1") + manager.addFailedSfuId("sfu-edge-2") + manager.addFailedSfuId("") + manager.addFailedSfuId(" ") + + assertThat(manager.failedSfuIdsSnapshot()).containsExactly("sfu-edge-1", "sfu-edge-2") + + manager.clearFailedSfuIds() + assertThat(manager.failedSfuIdsSnapshot()).isEmpty() + } + + @Test + fun `failed sfu id snapshot is a copy`() { + val manager = manager() + manager.addFailedSfuId("sfu-edge-1") + val snapshot = manager.failedSfuIdsSnapshot() + + manager.addFailedSfuId("sfu-edge-2") + + assertThat(snapshot).containsExactly("sfu-edge-1") + assertThat(manager.failedSfuIdsSnapshot()).hasSize(2) + } + @Test fun `connection and reconnection times are measured from their start timestamps`() { val manager = manager() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt index e59c3d4c7d5..3afd6d458b1 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/FailedSfuIdsTest.kt @@ -16,9 +16,10 @@ package io.getstream.video.android.core.reconnect -import io.getstream.video.android.core.Call +import io.getstream.video.android.core.addFailedSfuId import io.getstream.video.android.core.base.IntegrationTestBase import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.failedSfuIds import io.getstream.video.android.core.injectLocation import io.getstream.video.android.core.injectMockNetwork import io.getstream.video.android.core.injectSession @@ -37,43 +38,14 @@ import kotlin.test.assertTrue @RunWith(RobolectricTestRunner::class) class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { - private fun Call.reconnector(): Any { - val field = Call::class.java.getDeclaredField("reconnector") - field.isAccessible = true - return field.get(this) - } - - @Suppress("UNCHECKED_CAST") - private fun Call.getFailedSfuIds(): MutableSet { - val reconnector = reconnector() - val field = reconnector.javaClass.getDeclaredField("failedSfuIds") - field.isAccessible = true - return field.get(reconnector) as MutableSet - } - - private fun Call.invokeAddFailedSfuId(sfuId: String) { - val reconnector = reconnector() - val method = reconnector.javaClass.getDeclaredMethod("addFailedSfuId", String::class.java) - method.isAccessible = true - method.invoke(reconnector, sfuId) - } - - private fun Call.invokeGetFailedSfuIdsSnapshot(): List { - val reconnector = reconnector() - val method = reconnector.javaClass.getDeclaredMethod("getFailedSfuIdsSnapshot") - method.isAccessible = true - @Suppress("UNCHECKED_CAST") - return method.invoke(reconnector) as List - } - @Test fun `addFailedSfuId adds unique SFU IDs`() = runTest { val call = client.call("default", randomUUID()) - call.invokeAddFailedSfuId("sfu-edge-1") - call.invokeAddFailedSfuId("sfu-edge-2") + call.addFailedSfuId("sfu-edge-1") + call.addFailedSfuId("sfu-edge-2") - val ids = call.getFailedSfuIds() + val ids = call.failedSfuIds() assertEquals(2, ids.size) assertTrue(ids.contains("sfu-edge-1")) assertTrue(ids.contains("sfu-edge-2")) @@ -83,47 +55,47 @@ class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { fun `addFailedSfuId does not add duplicates`() = runTest { val call = client.call("default", randomUUID()) - call.invokeAddFailedSfuId("sfu-edge-1") - call.invokeAddFailedSfuId("sfu-edge-1") - call.invokeAddFailedSfuId("sfu-edge-1") + call.addFailedSfuId("sfu-edge-1") + call.addFailedSfuId("sfu-edge-1") + call.addFailedSfuId("sfu-edge-1") - assertEquals(1, call.getFailedSfuIds().size) + assertEquals(1, call.failedSfuIds().size) } @Test fun `addFailedSfuId ignores blank strings`() = runTest { val call = client.call("default", randomUUID()) - call.invokeAddFailedSfuId("") - call.invokeAddFailedSfuId(" ") + call.addFailedSfuId("") + call.addFailedSfuId(" ") - assertTrue(call.getFailedSfuIds().isEmpty()) + assertTrue(call.failedSfuIds().isEmpty()) } @Test fun `getFailedSfuIdsSnapshot returns a copy`() = runTest { val call = client.call("default", randomUUID()) - call.invokeAddFailedSfuId("sfu-edge-1") - val snapshot = call.invokeGetFailedSfuIdsSnapshot() + call.addFailedSfuId("sfu-edge-1") + val snapshot = call.failedSfuIds() - call.invokeAddFailedSfuId("sfu-edge-2") + call.addFailedSfuId("sfu-edge-2") assertEquals(1, snapshot.size) - assertEquals(2, call.getFailedSfuIds().size) + assertEquals(2, call.failedSfuIds().size) } @Test fun `onSfuConnectionEstablished clears failed SFU IDs`() = runTest { val call = client.call("default", randomUUID()) - call.invokeAddFailedSfuId("sfu-edge-1") - call.invokeAddFailedSfuId("sfu-edge-2") - assertEquals(2, call.getFailedSfuIds().size) + call.addFailedSfuId("sfu-edge-1") + call.addFailedSfuId("sfu-edge-2") + assertEquals(2, call.failedSfuIds().size) call.onSfuConnectionEstablished() - assertTrue(call.getFailedSfuIds().isEmpty()) + assertTrue(call.failedSfuIds().isEmpty()) } @Test @@ -146,17 +118,17 @@ class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { call.migrate() - assertTrue(call.getFailedSfuIds().contains("sfu-edge-old")) + assertTrue(call.failedSfuIds().contains("sfu-edge-old")) } @Test fun `failed SFU IDs accumulate across multiple addFailedSfuId calls`() = runTest { val call = client.call("default", randomUUID()) - call.invokeAddFailedSfuId("sfu-edge-1") - call.invokeAddFailedSfuId("sfu-edge-2") + call.addFailedSfuId("sfu-edge-1") + call.addFailedSfuId("sfu-edge-2") - val ids = call.getFailedSfuIds() + val ids = call.failedSfuIds() assertTrue(ids.contains("sfu-edge-1")) assertTrue(ids.contains("sfu-edge-2")) assertEquals(2, ids.size) @@ -166,13 +138,13 @@ class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { fun `onSfuConnectionEstablished after migrate clears accumulated IDs`() = runTest { val call = client.call("default", randomUUID()) - call.invokeAddFailedSfuId("sfu-edge-1") - call.invokeAddFailedSfuId("sfu-edge-2") - call.invokeAddFailedSfuId("sfu-edge-3") - assertFalse(call.getFailedSfuIds().isEmpty()) + call.addFailedSfuId("sfu-edge-1") + call.addFailedSfuId("sfu-edge-2") + call.addFailedSfuId("sfu-edge-3") + assertFalse(call.failedSfuIds().isEmpty()) call.onSfuConnectionEstablished() - assertTrue(call.getFailedSfuIds().isEmpty()) + assertTrue(call.failedSfuIds().isEmpty()) } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt index 7da800f0440..04c7d78f4ed 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt @@ -79,6 +79,7 @@ class JoinRecoverableFailureTest { private lateinit var clientImpl: StreamVideoClient private lateinit var state: CallState private lateinit var sessionManager: CallSessionManager + private lateinit var apiClient: CallApiClient private lateinit var reconnector: CallReconnector private lateinit var mockSession: RtcSession private lateinit var mockJoinResponse: JoinCallResponse @@ -93,6 +94,7 @@ class JoinRecoverableFailureTest { mockJoinResponse = mockk(relaxed = true) sessionManager = CallSessionManager() + apiClient = mockk(relaxed = true) connectionFlow = MutableStateFlow(RealtimeConnection.InProgress) every { state._connection } returns connectionFlow @@ -100,9 +102,8 @@ class JoinRecoverableFailureTest { every { state.settings } returns MutableStateFlow(null) coEvery { clientImpl.getCachedLocation() } returns Success("test-location") coEvery { - clientImpl.joinCall( - any(), any(), any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any(), + apiClient.joinRequest( + any(), any(), any(), any(), any(), any(), any(), any(), ) } returns Success(mockJoinResponse) } @@ -123,7 +124,7 @@ class JoinRecoverableFailureTest { sessionFactory = RtcSessionFactory { _, _, _, _, _, _, _ -> mockSession }, media = mockk(relaxed = true), lifecycle = mockk(relaxed = true), - apiClient = mockk(relaxed = true), + apiClient = apiClient, reconnector = reconnector, sessionMonitor = mockk(relaxed = true), callRegistry = mockk(relaxed = true), From 9c374ea835c883c9294a8eba5d15c7b63bc99448 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Wed, 5 Aug 2026 12:35:45 +0530 Subject: [PATCH 10/10] chore: remove unused code (#1762) --- .../io/getstream/video/android/core/Call.kt | 42 ------------------- .../core/call/components/CallMediaManager.kt | 4 ++ 2 files changed, 4 insertions(+), 42 deletions(-) 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 65951028d49..a7f20b8fade 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 @@ -25,7 +25,6 @@ import androidx.compose.runtime.Stable import io.getstream.android.video.generated.models.AcceptCallResponse import io.getstream.android.video.generated.models.BlockUserResponse import io.getstream.android.video.generated.models.CallSettingsRequest -import io.getstream.android.video.generated.models.CallSettingsResponse import io.getstream.android.video.generated.models.GetCallResponse import io.getstream.android.video.generated.models.GetOrCreateCallResponse import io.getstream.android.video.generated.models.GoLiveResponse @@ -402,8 +401,6 @@ public class Call( */ var audioFilter: InputAudioFilter? = null - // val monitor = CallHealthMonitor(this, scope, onIceRecoveryFailed) - /** * This returns the local microphone volume level. The audio volume is a linear * value between 0 (no sound) and 1 (maximum volume). This is not a raw output - @@ -425,11 +422,6 @@ public class Call( */ val statLatencyHistory: MutableStateFlow> get() = statsReporter.statLatencyHistory - /** - * Call has been left and the object is cleaned up and destroyed. - */ - internal val isDestroyed: Boolean get() = lifecycle.isDestroyed - internal var peerConnectionFactory: StreamPeerConnectionFactory get() = media.peerConnectionFactory set(value) { @@ -447,15 +439,6 @@ public class Call( callSessionId = { sessionId }, ) - /** - * Checks if the audioBitrateProfile has changed since the factory was created, - * and recreates the factory if needed. This should only be called before joining. - * - * If the factory hasn't been created yet, it will be created with the current profile - * when first accessed, so no recreation is needed. - */ - internal fun ensureFactoryMatchesAudioProfile() = media.ensureFactoryMatchesAudioProfile() - internal val clientCapabilities = ConcurrentHashMap().apply { put( ClientCapability.CLIENT_CAPABILITY_SUBSCRIBER_VIDEO_PAUSE.name, @@ -561,31 +544,6 @@ public class Call( callJoinInterceptor, ) - internal fun isPermanentError(error: Any): Boolean = joinCoordinator.isPermanentError(error) - - internal suspend fun _join( - create: Boolean = false, - createOptions: CreateCallOptions? = null, - ring: Boolean = false, - notify: Boolean = false, - hintHighScaleLivestreamPublisher: Boolean? = null, - joinAnalyticsModel: JoinAnalyticsModel, - ): Result = joinCoordinator.joinInternal( - create, - createOptions, - ring, - notify, - hintHighScaleLivestreamPublisher, - joinAnalyticsModel, - ) - - /** Resets the leave guard so a fresh join can run after a previous leave. */ - internal fun resetLeaveGuard() = lifecycle.resetLeaveGuard() - - /** Applies server-provided call settings to the local media manager. */ - internal fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) = - media.updateMediaManagerFromSettings(callSettings) - internal suspend fun collectStats(): CallStatsReport = statsReporter.collectStats() // region Reconnection — unified loop diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt index a35fee18365..d3477ff8bb8 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -116,6 +116,9 @@ internal class CallMediaManager( /** * Checks if the audioBitrateProfile has changed since the factory was created, * and recreates the factory if needed. This should only be called before joining. + * + * If the factory hasn't been created yet, it will be created with the current profile + * when first accessed, so no recreation is needed. */ fun ensureFactoryMatchesAudioProfile() { val factory = _peerConnectionFactory @@ -173,6 +176,7 @@ internal class CallMediaManager( // Next access to peerConnectionFactory will recreate it with current profile } + /** Applies server-provided call settings to the local media manager. */ fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) { val camera = mediaManager.camera val microphone = mediaManager.microphone