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 5166453fd86..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 @@ -23,10 +23,8 @@ 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 import io.getstream.android.video.generated.models.GetCallResponse import io.getstream.android.video.generated.models.GetOrCreateCallResponse import io.getstream.android.video.generated.models.GoLiveResponse @@ -47,96 +45,66 @@ 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.SfuConnectFailureCause -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.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.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.first -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 " + @@ -145,28 +113,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 * @@ -185,113 +131,113 @@ 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) - // Unit-test only hook for replacing RtcSession construction. - // TODO(v2): replace this with a proper dependency injection boundary. - internal var unitTestRtcSessionFactory: (() -> RtcSession)? = null - - // 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) - // Must be initialized before `state` — CallState → SortedParticipantsState - // launches a coroutine that reads `call.events` (leaking-this race). - val events = MutableSharedFlow(extraBufferCapacity = 150) - - /** 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 } - - /** The cid is type:id */ - val cid = "$type:$id" + /** Delegate that owns the live RTC session state and reconnect bookkeeping. */ + private val sessionManager = CallSessionManager() /** - * Set a custom [VideoFilter] that will be applied to the video stream coming from your device. - */ - var videoFilter: VideoFilter? = null - - /** - * Set a custom [InputAudioFilter] that will be applied to the audio stream recorded on your device. + * 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. */ - var audioFilter: InputAudioFilter? = null - - // val monitor = CallHealthMonitor(this, scope, onIceRecoveryFailed) - - private val soundInputProcessor = SoundInputProcessor(thresholdCrossedCallback = { - if (!microphone.isEnabled.value) { - state.markSpeakingAsMuted() + // 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 + get() = sessionManager.sessionId + set(value) { + sessionManager.sessionId = value } - }) - 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 - - * it is a smoothed-out volume level that gradually goes to the highest measured level - * and will then gradually over 250ms return back to 0 or next measured value. This value - * can be used directly in your UI for displaying a volume/speaking indicator for the local - * participant. - * Note: Doesn't return any values until the session is established! - */ - val localMicrophoneAudioLevel: StateFlow = audioLevelOutputHelper.currentLevel - - /** - * Contains stats events for observation. - */ - val statsReport: MutableStateFlow = MutableStateFlow(null) + // Unit-test only hook for replacing RtcSession construction. + // TODO(v2): replace this with a proper dependency injection boundary. + internal var unitTestRtcSessionFactory: (() -> RtcSession)? = null /** - * Contains stats history. + * Creates [RtcSession] instances for join / rejoin / migrate. Captures `this` so the + * 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. */ - val statLatencyHistory: MutableStateFlow> = MutableStateFlow(listOf(0, 0, 0)) + private val sessionFactory = RtcSessionFactory { + sessionId, sessionCounter, sfuUrl, sfuWsUrl, sfuToken, sfuName, iceServers -> + unitTestRtcSessionFactory?.invoke() ?: RtcSession( + client = clientImpl, + sessionCounter = sessionCounter, + powerManager = powerManager, + call = this, + sessionManager = sessionManager, + 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) + }, + ) + } /** - * Time (in millis) when the full reconnection flow started. Will be null again once - * the reconnection flow ends (success or failure) + * 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 var sfuSocketReconnectionTime: Long? = null + private val callRegistry = object : ClientCallRegistry { + override fun markRinging() { + clientImpl.state._ringingCall.value = this@Call + } - /** - * Call has been left and the object is cleaned up and destroyed. - */ - private var isDestroyed = false + override fun registerOutgoingRing() { + client.state.addRingingCall(this@Call, RingingState.Outgoing()) + } - /** Session handles all real time communication for video and audio */ - internal val session: MutableStateFlow = MutableStateFlow(null) + override fun markActive() { + client.state.setActiveCall(this@Call) + } - var sessionId = UUID.randomUUID().toString() - internal val unifiedSessionId = UUID.randomUUID().toString() + override fun markAccepted() { + clientImpl.state.transitionToAcceptCall(this@Call) + } - /** - * 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() + 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) + } + } - internal var connectStartTime = 0L - internal var reconnectStartTime = 0L + // --------------------------------------------------------------------------------------- + // 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 @@ -301,27 +247,19 @@ public class Call( EglBase.create() } - // peerConnectionFactory is nullable and recreated when audioBitrateProfile changes (before joining) - private var _peerConnectionFactory: StreamPeerConnectionFactory? = null + /** Delegate that restarts ICE when the publisher/subscriber connections drop. */ + private val iceMonitor: CallIceConnectionMonitor = + CallIceConnectionMonitor(type, id, scope, sessionManager) - 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!! - } - set(value) { - _peerConnectionFactory = value - } + /** 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( @@ -335,68 +273,171 @@ public class Call( 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, + ) + /** - * 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. + * Creates [MediaManagerImpl] for this call. Captures `this` (and the test hook) so + * [CallMediaManager] never needs a Call reference. */ - internal fun ensureFactoryMatchesAudioProfile() { - val factory = _peerConnectionFactory + private val mediaManagerFactory = MediaManagerFactory { audioUsage, audioUsageProvider -> + testInstanceProvider.mediaManagerCreator?.invoke() + ?: MediaManagerImpl( + clientImpl.context, + this, + scope, + eglBase.eglBaseContext, + audioUsage, + audioUsageProvider, + ) + } - // If factory hasn't been created yet, it will be created with current profile automatically - if (factory == null) { - return - } + /** 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, + ) - // Check if current profile differs from the profile used to create the factory - val factoryProfile = factory.audioBitrateProfile - val currentProfile = mediaManager.microphone.audioBitrateProfile.value + /** Delegate that owns leave / end / cleanup teardown and the destroyed flag. */ + 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() + }, + state = state, + callAnalytics = callAnalytics, + statsReporter = statsReporter, + media = media, + iceMonitor = iceMonitor, + sessionMonitor = { sessionMonitor }, + connectivityMonitor = { connectivityMonitor }, + type = type, + id = id, + ) - if (factoryProfile != null && currentProfile != factoryProfile) { - logger.i { - "Audio bitrate profile changed from $factoryProfile to $currentProfile. " + - "Recreating factory before joining." - } - recreateFactoryAndAudioTracks() - } - } + /** Delegate that owns the unified reconnect state machine (fast / rejoin / migrate). */ + private val reconnector: CallReconnector = CallReconnector( + clientImpl = clientImpl, + sessionManager = sessionManager, + sessionFactory = sessionFactory, + lifecycle = lifecycle, + apiClient = apiClient, + state = state, + callAnalytics = callAnalytics, + statsReporter = statsReporter, + sessionMonitor = { sessionMonitor }, + type = type, + id = id, + ) + + /** 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 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 + val microphone get() = mediaManager.microphone + val speaker get() = mediaManager.speaker + val screenShare get() = mediaManager.screenShare + + /** The cid is type:id */ + val cid = "$type:$id" /** - * Recreates peerConnectionFactory, audioSource, audioTrack, videoSource and videoTrack - * with the current audioBitrateProfile. This should only be called before the call is joined. + * Set a custom [VideoFilter] that will be applied to the video stream coming from your device. */ - internal fun recreateFactoryAndAudioTracks() { - val wasMicrophoneEnabled = microphone.status.value is DeviceStatus.Enabled - val wasCameraEnabled = camera.status.value is DeviceStatus.Enabled + var videoFilter: VideoFilter? = null - // Dispose all tracks and sources first - mediaManager.disposeTracksAndSources() + /** + * Set a custom [InputAudioFilter] that will be applied to the audio stream recorded on your device. + */ + var audioFilter: InputAudioFilter? = null - // Recreate the factory (which will use the new audioBitrateProfile) - recreatePeerConnectionFactory() + /** + * 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 - + * it is a smoothed-out volume level that gradually goes to the highest measured level + * and will then gradually over 250ms return back to 0 or next measured value. This value + * can be used directly in your UI for displaying a volume/speaking indicator for the local + * participant. + * Note: Doesn't return any values until the session is established! + */ + val localMicrophoneAudioLevel: StateFlow get() = media.localMicrophoneAudioLevel - // 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) - } - } + /** + * Contains stats events for observation. + */ + val statsReport: MutableStateFlow get() = statsReporter.statsReport /** - * Recreates peerConnectionFactory with the current audioBitrateProfile. - * This should only be called before the call is joined. + * Contains stats history. */ - internal fun recreatePeerConnectionFactory() { - _peerConnectionFactory?.dispose() - _peerConnectionFactory = null - // Next access to peerConnectionFactory will recreate it with current profile - } + val statLatencyHistory: MutableStateFlow> get() = statsReporter.statLatencyHistory + + internal var peerConnectionFactory: StreamPeerConnectionFactory + get() = media.peerConnectionFactory + set(value) { + media.peerConnectionFactory = value + } + + /** Delegate that binds video tracks to renderers and handles media-quality overrides. */ + private val callRenderer = CallRenderer( + type = type, + id = id, + scope = scope, + sessionManager = sessionManager, + callAnalytics = callAnalytics, + eglBase = { eglBase }, + callSessionId = { sessionId }, + ) internal val clientCapabilities = ConcurrentHashMap().apply { put( @@ -405,92 +446,45 @@ 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 } - } - } - - 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:$reconnectDeadlineMillis" - } - val strategy = if (lastDisconnect > 0 && elapsedTimeMils < reconnectDeadlineMillis) { - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST - } else { - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN - } - reconnect(strategy, "NetworkStateListener#onConnected") - } + internal val mediaManager get() = media.mediaManager + + /** 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 + }, + ) - 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 + get() = sessionManager.reconnectDeadlineMillis + set(value) { + sessionManager.reconnectDeadlineMillis = value } - } - - private var leaveTimeoutAfterDisconnect: Job? = null - private var lastDisconnect = 0L - private var reconnectDeadlineMillis: Int = 10_000 - private val reconnectMutex = Mutex() - - private var monitorPublisherPCStateJob: Job? = null - private var monitorSubscriberPCStateJob: Job? = null - private var sfuListener: Job? = null - private var sfuEvents: Job? = null 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( @@ -503,67 +497,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, @@ -572,936 +523,68 @@ 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() - - // 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 Failure(value = Error.GenericError(errorMessage)) - } + ): Result = joinCoordinator.join( + create, + createOptions, + ring, + notify, + hintHighScaleLivestreamPublisher, + callJoinInterceptor, + ) suspend fun joinAndRing( members: List, 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})", - ), - ) - } - } - } - - internal fun isPermanentError(error: Any): Boolean { - if (error is Error.ThrowableError) { - if (error.message.contains("Unable to resolve host")) { - return false - } - } - return true - } - - internal suspend fun _join( - create: Boolean = false, - createOptions: CreateCallOptions? = null, - ring: Boolean = false, - notify: Boolean = false, - hintHighScaleLivestreamPublisher: Boolean? = null, - joinAnalyticsModel: JoinAnalyticsModel, - ): Result { - nonFastReconnectAttempts = 0 - 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 (unitTestRtcSessionFactory != null) { - unitTestRtcSessionFactory!!.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 - - state._connection.value = RealtimeConnection.Joined(localSession) - - // This is the SFU ws connection - val sfuConnectionResult = localSession.connectInternal() - - when (sfuConnectionResult) { - is SfuConnectionResult.Success -> Unit - is SfuConnectionResult.Failure -> { - when (sfuConnectionResult.cause) { - SfuConnectFailureCause.SocketStateObservationTimeout -> { - // REJOIN (not FAST) on purpose: a connect timeout means the initial - // join never completed. There is no established SFU session or - // negotiated media path to resume, so a FAST resume would likely hit - // PARTICIPANT_NOT_FOUND and burn attempts before the loop escalates. - // A full REJOIN re-fetches credentials and starts a clean join, which - // is the only thing that can actually succeed here. - logger.w { - "[_join] SFU socket state observation timed out with no recovery started — triggering REJOIN" - } - scope.launch { - reconnect( - WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, - "join-recoverable-connect-failure", - ) - } - } - - SfuConnectFailureCause.RecoverableSocketFailure -> { - logger.w { "[_join] Recoverable SFU socket failure — awaiting recovery outcome" } - } - - SfuConnectFailureCause.TerminalSocketFailure -> { - logger.e { - "[_join] Got terminal error while connecting to SFU. Error : $sfuConnectionResult" - } - sendJoinErrorAnalytics(sfuConnectionResult) - return Failure( - Error.GenericError( - sfuConnectionResult.error.message ?: "RtcSession error occurred.", - ), - ) - } - } - - if (sfuConnectionResult.cause != SfuConnectFailureCause.TerminalSocketFailure) { - if (!didReconnectSucceed()) { - logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" } - sendJoinErrorAnalytics(sfuConnectionResult) - return Failure( - Error.GenericError( - sfuConnectionResult.error.message ?: "SFU connection failed", - ), - ) - } - } - } - } - val connectedSession = session.value ?: return Failure(Error.GenericError("RtcSession was cleared during connection to sfu")) - client.state.setActiveCall(this) - // 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) { - monitorSession(result.value) - } - return Success(value = connectedSession) - } - - /** - * Reports the SFU WebSocket join failure to analytics. Only called from the join - * flow ([_join]) so that reconnect-driven [RtcSession.connectInternal] failures are - * not counted as join errors. The retry count comes from the session's - * [RtcSession.sfuWsRetryCount]; the failure reason and abort code come straight from - * the [SfuConnectionResult.Failure] the connect attempt produced. - */ - private fun sendJoinErrorAnalytics(failure: SfuConnectionResult.Failure) { - callAnalytics.sfuAnalytics.onSfuWsCompleted( - success = false, - retryCount = session.value?.sfuWsRetryCount?.get() ?: 0, - failureReason = failure.error.message, - failureCode = (failure.abortReason ?: AnalyticsCallAbortReason.SFU_ERROR).name, - ) - } - - /** - * Suspends until the reconnect loop triggered by a recoverable connection failure - * reaches a terminal state, returning `true` if the call recovered (became - * [RealtimeConnection.Connected]) and `false` otherwise - * ([RealtimeConnection.ReconnectingFailed] / [RealtimeConnection.Disconnected]). - */ - private suspend fun didReconnectSucceed(): Boolean { - val terminal = state.connection.first { - it is RealtimeConnection.Connected || - it is RealtimeConnection.ReconnectingFailed || - it is RealtimeConnection.Disconnected - } - logger.d { "[_join] Reconnect after recoverable connection failure settled on $terminal" } - return terminal is RealtimeConnection.Connected - } - - private fun Call.monitorSession(result: JoinCallResponse) { - sfuEvents?.cancel() - sfuListener?.cancel() - startCallStatsReporting(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" } - } - } - } - } - 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) - } - - 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) - } + ): Result = joinCoordinator.joinAndRing( + members, + createOptions, + video, + callJoinInterceptor, + ) - 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, - ) - ) { - is SfuConnectionResult.Success -> { - newSession.sfuTracer.trace("rejoin", reason) - monitorSession(joinResponse.value) - ReconnectOutcome.Success - } - is SfuConnectionResult.Failure -> 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, - ) - when (result) { - is SfuConnectionResult.Success -> { - monitorSession(joinResponse.value) - ReconnectOutcome.Success - } - is SfuConnectionResult.Failure -> 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 + fun leave(reason: CallLeaveReason) = lifecycle.leave(reason) - /** - * 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) - - (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, @@ -1509,49 +592,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, @@ -1560,29 +614,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, - ) - } + ) = callRenderer.setVisibility(sessionId, trackType, visible, viewportId, width, height) - fun handleEvent(event: VideoEvent) { - logger.v { "[call handleEvent] #sfu; event.type: ${event.getEventType()}" } - - when (event) { - is GoAwayEvent -> - scope.launch { - migrate() - } - } - } + fun handleEvent(event: VideoEvent) = eventManager.handleEvent(event) // TODO: review this /** @@ -1598,70 +632,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. @@ -1685,48 +656,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 @@ -1740,41 +692,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, @@ -1783,153 +712,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) - } + ): Result = apiClient.revokePermissions(userId, permissions) - public suspend fun updateMembers(memberRequests: List): Result { - val request = UpdateCallMembersRequest(updateMembers = memberRequests) - return clientImpl.updateMembers(type, id, request) - } + public suspend fun updateMembers(memberRequests: List): Result = + apiClient.updateMembers(memberRequests) - 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() - - // 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. @@ -1940,65 +762,28 @@ 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) - } - - /** 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() - } + ): Result = apiClient.muteUsers(userIds, audio, video, screenShare) /** * 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() + sessionManager.clearFailedSfuIds() } @VisibleForTesting @@ -2011,87 +796,35 @@ 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 - } - - 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() - } - - // This will allow the Rest APIs to be executed which are in queue before leave - private fun shutDownJobsGracefully() { - UserScope(ClientScope()).launch { - supervisorJob.children.forEach { it.join() } - supervisorJob.cancel() - } - scope.cancel() - } + ): Result = apiClient.joinRequest( + create, + location, + migratingFrom, + migratingFromList, + ring, + notify, + hintHighScaleLivestreamPublisher, + joinAnalyticsModel, + ) - suspend fun ring(): Result { - logger.d { "[ring] #ringing; no args" } - return clientImpl.ring(type, id) - } + fun cleanup() = lifecycle.cleanup() - 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( @@ -2102,51 +835,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( @@ -2170,37 +867,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) @@ -2215,14 +901,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. @@ -2230,9 +909,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. @@ -2245,18 +923,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) @@ -2304,24 +972,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 the socket connection deadline before it gives - * up: OkHttp's WebSocket-upgrade timeout, followed by the join-response - * wait — both driven by StreamVideoBuilder.connectionTimeoutInMs. */ - 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/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/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/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..971d05a47bd --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallApiClient.kt @@ -0,0 +1,450 @@ +/* + * 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.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 +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.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 +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 [CallState] from the response. + * + * This component holds no mutable call state — it is a stateless façade over the + * coordinator endpoints, extracted from the call to keep the public class focused. + */ +internal class CallApiClient( + 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 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 { + 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 { + // 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) { + callRegistry.markRinging() + } + state.updateFromResponse(it) + if (ring) { + callRegistry.registerOutgoingRing() + } + } + 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 + + callRegistry.markAccepted() + 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, + ) { + scope.launch { + clientImpl.collectFeedback( + callType = type, + id = id, + sessionId = callSessionId(), + 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..9d78ffe462c --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitor.kt @@ -0,0 +1,117 @@ +/* + * 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.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: + * 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 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:$type:$id") + + 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 + val deadlineMillis = reconnectDeadlineMillis() + logger.d { + "[NetworkStateListener#onConnected] #network; no args, elapsedTimeMils:$elapsedTimeMils, lastDisconnect:$lastDisconnect, reconnectDeadlineMils:$deadlineMillis" + } + val strategy = if (lastDisconnect > 0 && elapsedTimeMils < deadlineMillis) { + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST + } else { + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN + } + reconnector.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 = 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)" + } + lifecycle.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..d70062057c0 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallEventManager.kt @@ -0,0 +1,103 @@ +/* + * 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.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 + * [EventSubscription]s, and the dispatch / handling of incoming [VideoEvent]s. + */ +internal class CallEventManager( + private val type: String, + private val id: String, + private val scope: CoroutineScope, + // 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") + + 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 -> + scope.launch { + reconnector().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..0c9e0d49e88 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitor.kt @@ -0,0 +1,95 @@ +/* + * 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 kotlinx.coroutines.CoroutineScope +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 type: String, + private val id: String, + private val scope: CoroutineScope, + private val sessionManager: CallSessionManager, +) { + private val logger by taggedLogger("Call:IceMonitor:$type:$id") + + private var monitorPublisherPCStateJob: Job? = null + private var monitorSubscriberPCStateJob: Job? = null + + fun start() { + startPublisherMonitor() + startSubscriberMonitor() + } + + private fun startPublisherMonitor() { + monitorPublisherPCStateJob?.cancel() + monitorPublisherPCStateJob = scope.launch { + sessionManager.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 = scope.launch { + sessionManager.session.value?.subscriber?.value?.iceState?.collect { + when (it) { + PeerConnection.IceConnectionState.FAILED, PeerConnection.IceConnectionState.DISCONNECTED -> { + sessionManager.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..4ea7451beb3 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -0,0 +1,369 @@ +/* + * 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.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.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 +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 + * underlying join request to the coordinator, and creation + connection of the [RtcSession]. + */ +internal class CallJoinCoordinator( + 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:$type:$id") + + private fun isVideoEnabled(): Boolean = state.settings.value?.video?.enabled ?: false + + 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" + } + // Check android permissions and log a warning to make sure developers requested adequate permissions prior to using the call. + 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" + + "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 + media.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 + + lifecycle.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) { + media.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) { + sessionManager.setActiveSession(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) + } + sessionManager.setActiveSession(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 = 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" } + apiClient.ring(RingCallRequest(isVideoEnabled(), members)).map { + logger.d { "[joinAndRing] Ringed #ringing; #track; ring: $members" } + callRegistry.markRinging() + rtcSession + }.onError { + logger.e { "[joinAndRing] Ring failed #ringing; #track; error: $it" } + state.toggleJoinAndRingProgress(false) + lifecycle.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 { + sessionManager.nonFastReconnectAttempts = 0 + sessionMonitor.cancelSfuObservers() + + 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" + } + + 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 + } + sessionManager.location = locationResult.value + + val options = createOptions + ?: if (create) { + CreateCallOptions() + } else { + null + } + val result = + apiClient.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 = 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) + + // This is the SFU ws connection + val sfuConnectionResult = localSession.connectInternal() + + when (sfuConnectionResult) { + is SfuConnectionResult.Success -> Unit + is SfuConnectionResult.Failure -> { + when (sfuConnectionResult.cause) { + SfuConnectFailureCause.SocketStateObservationTimeout -> { + // REJOIN (not FAST) on purpose: a connect timeout means the initial + // join never completed. There is no established SFU session or + // negotiated media path to resume, so a FAST resume would likely hit + // PARTICIPANT_NOT_FOUND and burn attempts before the loop escalates. + // A full REJOIN re-fetches credentials and starts a clean join, which + // is the only thing that can actually succeed here. + logger.w { + "[_join] SFU socket state observation timed out with no recovery started — triggering REJOIN" + } + scope.launch { + reconnector.reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + "join-recoverable-connect-failure", + ) + } + } + + SfuConnectFailureCause.RecoverableSocketFailure -> { + logger.w { "[_join] Recoverable SFU socket failure — awaiting recovery outcome" } + } + + SfuConnectFailureCause.TerminalSocketFailure -> { + logger.e { + "[_join] Got terminal error while connecting to SFU. Error : $sfuConnectionResult" + } + sendJoinErrorAnalytics(sfuConnectionResult) + return Failure( + Error.GenericError( + sfuConnectionResult.error.message ?: "RtcSession error occurred.", + ), + ) + } + } + + if (sfuConnectionResult.cause != SfuConnectFailureCause.TerminalSocketFailure) { + if (!didReconnectSucceed()) { + logger.e { "[_join] Could not recover. Error : $sfuConnectionResult" } + sendJoinErrorAnalytics(sfuConnectionResult) + return Failure( + Error.GenericError( + sfuConnectionResult.error.message ?: "SFU connection failed", + ), + ) + } + } + } + } + val connectedSession = sessionManager.session.value + ?: return Failure(Error.GenericError("RtcSession was cleared during connection to sfu")) + 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) { + sessionMonitor.monitorSession(result.value) + } + return Success(value = connectedSession) + } + + /** + * Reports the SFU WebSocket join failure to analytics. Only called from the join + * flow ([joinInternal]) so that reconnect-driven [RtcSession.connectInternal] failures + * are not counted as join errors. The retry count comes from the session's + * [RtcSession.sfuWsRetryCount]; the failure reason and abort code come straight from + * the [SfuConnectionResult.Failure] the connect attempt produced. + */ + private fun sendJoinErrorAnalytics(failure: SfuConnectionResult.Failure) { + callAnalytics.sfuAnalytics.onSfuWsCompleted( + success = false, + retryCount = sessionManager.session.value?.sfuWsRetryCount?.get() ?: 0, + failureReason = failure.error.message, + failureCode = (failure.abortReason ?: AnalyticsCallAbortReason.SFU_ERROR).name, + ) + } + + /** + * Suspends until the reconnect loop triggered by a recoverable connection failure + * reaches a terminal state, returning `true` if the call recovered (became + * [RealtimeConnection.Connected]) and `false` otherwise + * ([RealtimeConnection.ReconnectingFailed] / [RealtimeConnection.Disconnected]). + */ + private suspend fun didReconnectSucceed(): Boolean { + val terminal = state.connection.first { + it is RealtimeConnection.Connected || + it is RealtimeConnection.ReconnectingFailed || + it is RealtimeConnection.Disconnected + } + logger.d { "[_join] Reconnect after recoverable connection failure settled on $terminal" } + return terminal is RealtimeConnection.Connected + } +} 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..ba1a4174e8f --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -0,0 +1,158 @@ +/* + * 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.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.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: 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 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, + 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 connectivityMonitor: () -> CallConnectivityMonitor, + private val type: String, + private val id: String, +) { + private val logger by taggedLogger("Call:LifecycleManager:$type:$id") + + private val cid = "$type:$id" + + // 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:$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 { + stopConnectionMonitors() + callAnalytics.stopObservers() + sessionMonitor().cancelSfuObservers() + 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) + */ + media.disableLocalCapture() + + callRegistry.detach() + + clientImpl.scope.launch { + val leaveReason = "[reason=${reason::class.simpleName}, message=${reason.message}]" + callAnalytics.onCallLeave(sessionManager.session, reason) + safeCall { + 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 { sessionManager.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(type, 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() + 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 + 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 new file mode 100644 index 00000000000..d3477ff8bb8 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -0,0 +1,290 @@ +/* + * 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.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.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.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 + * [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 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, + private val id: String, + private val clientImpl: StreamVideoClient, + private val scope: CoroutineScope, + private val state: CallState, + private val sessionManager: CallSessionManager, + private val eglBase: () -> EglBase, + private val mediaManagerFactory: MediaManagerFactory, +) { + private val logger by taggedLogger("Call:MediaManager:$type:$id") + + private val soundInputProcessor = SoundInputProcessor(thresholdCrossedCallback = { + if (!mediaManager.microphone.isEnabled.value) { + 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(type).audioUsage, + audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, + audioBitrateProfileProvider = { mediaManager.microphone.audioBitrateProfile.value }, + sharedEglBaseProvider = { eglBase() }, + webRtcLoggingLevel = clientImpl.loggingLevel.webRtcLoggingLevel, + ) + } + return _peerConnectionFactory!! + } + set(value) { + _peerConnectionFactory = value + } + + val mediaManager by lazy { + mediaManagerFactory.create( + audioUsage = clientImpl.callServiceConfigRegistry.get(type).audioUsage, + audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, + ) + } + + /** Starts streaming smoothed microphone audio levels into [localMicrophoneAudioLevel]. */ + fun startAudioLevelMonitoring() { + 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. + * + * 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 + + // 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 + } + + /** Applies server-provided call settings to the local media manager. */ + 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(scope) + } + + fun startScreenSharing( + mediaProjectionPermissionResultData: Intent, + includeAudio: Boolean = false, + ) { + if (state.ownCapabilities.value.contains(OwnCapability.Screenshare)) { + sessionManager.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() + } + + /** 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 new file mode 100644 index 00000000000..88c72d3dd37 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallReconnector.kt @@ -0,0 +1,532 @@ +/* + * 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.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.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 + +/** + * 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`. + * + * 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 clientImpl: StreamVideoClient, + private val sessionManager: CallSessionManager, + private val sessionFactory: RtcSessionFactory, + private val lifecycle: CallLifecycleManager, + 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 type: String, + private val id: String, +) { + private val logger by taggedLogger("Call:Reconnector:$type:$id") + + // 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() + + /** + * 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. + */ + + /** + * 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 (lifecycle.isDestroyed || conn is RealtimeConnection.Disconnected) { + logger.d { + "[reconnect] Call already left/destroyed (isDestroyed=${lifecycle.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 >= sessionManager.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 -> { + sessionManager.nonFastReconnectAttempts++ + reconnectRejoin( + reason, + JoinAnalyticsModel( + sessionManager.nonFastReconnectAttempts, + JoinReason.ReJoin, + ), + ) + } + + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE -> { + sessionManager.nonFastReconnectAttempts++ + reconnectMigrate( + JoinAnalyticsModel( + sessionManager.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" } + lifecycle.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 (${sessionManager.nonFastReconnectAttempts}) failed: ${outcome.error.message}" + } + + delay(RECONNECT_DELAY_MS) + loopIteration++ + + val wasMigrating = currentStrategy == + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE + val pastFastReconnectDeadline = (System.currentTimeMillis() - loopStartTime) > + sessionManager.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, + ) + lifecycle.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=${sessionManager.nonFastReconnectAttempts}" } + val currentSession = sessionManager.session.value + ?: return ReconnectOutcome.PreconditionNotMet("No active session for fast reconnect") + + val stats = statsReporter.collectStats() + currentSession.sendCallStats(stats) + + currentSession.prepareReconnect() + state._connection.value = RealtimeConnection.Reconnecting + sessionManager.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 = sessionManager.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=${sessionManager.nonFastReconnectAttempts}" } + state._connection.value = RealtimeConnection.Reconnecting + val loc = sessionManager.location + ?: return ReconnectOutcome.PreconditionNotMet("No location available for rejoin") + val oldSession = sessionManager.session.value + ?: return ReconnectOutcome.PreconditionNotMet("No active session for rejoin") + sessionManager.reconnectStartTime = System.currentTimeMillis() + + val joinResponse = apiClient.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}" } + + 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 = sessionManager.nonFastReconnectAttempts, + reason = reason, + ) + state.removeParticipant(prevSessionId) + oldSession.prepareRejoin("rejoin") + 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() }, + ) + sessionManager.setActiveSession(newSession) + + return when ( + val result = newSession.connectInternal( + reconnectDetails, + currentOptions, + ) + ) { + is SfuConnectionResult.Success -> { + newSession.sfuTracer.trace("rejoin", reason) + sessionMonitor().monitorSession(joinResponse.value) + ReconnectOutcome.Success + } + is SfuConnectionResult.Failure -> 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 = sessionManager.location + ?: return ReconnectOutcome.PreconditionNotMet("No location available for migrate") + val oldSession = sessionManager.session.value + ?: return ReconnectOutcome.PreconditionNotMet("No active session for migrate") + sessionManager.reconnectStartTime = System.currentTimeMillis() + sessionManager.addFailedSfuId(oldSession.sfuName) + + val joinResponse = + apiClient.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 = sessionManager.nonFastReconnectAttempts, + ) + + val stats = statsReporter.collectStats() + oldSession.sendCallStats(stats) + oldSession.enterMigration() + + 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() }, + ) + sessionManager.setActiveSession(newSession) + + return try { + val result = newSession.connectInternal( + reconnectDetails, + currentOptions, + ) + when (result) { + is SfuConnectionResult.Success -> { + sessionMonitor().monitorSession(joinResponse.value) + ReconnectOutcome.Success + } + is SfuConnectionResult.Failure -> 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") + } + + 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 the socket connection deadline before it gives + * up: OkHttp's WebSocket-upgrade timeout, followed by the join-response + * wait — both driven by StreamVideoBuilder.connectionTimeoutInMs. */ + 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..e5510534fd5 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRenderer.kt @@ -0,0 +1,222 @@ +/* + * 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.analytics.call.CallAnalytics +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.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.webrtc.EglBase +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. + * + * @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 type: String, + private val id: String, + private val scope: CoroutineScope, + private val sessionManager: CallSessionManager, + private val callAnalytics: CallAnalytics, + private val eglBase: () -> EglBase, + private val callSessionId: () -> String, +) { + private val logger by taggedLogger("Call:Renderer:$type:$id") + + fun setVisibility( + sessionId: String, + trackType: TrackType, + visible: Boolean, + viewportId: String = sessionId, + ) { + logger.i { + "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" + } + sessionManager.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" + } + sessionManager.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( + 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) { + sessionManager.session.value?.updateTrackDimensions( + sessionId, + trackType, + true, + VideoDimension(width, height), + viewportId, + ) + } + onRendered(videoRenderer) + callAnalytics.videoAnalytics.firstVideoFrameRendered( + + trackType, + width, + height, + rtcSession = sessionManager.session.value, + sessionId, + callSessionId(), + ) + } + + 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) { + sessionManager.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) + scope.launch { + track.video.removeSink(screenshotSink) + } + continuation.resume(bitmap) + } + + track.video.addSink(screenshotSink) + } + } + + fun setPreferredIncomingVideoResolution( + resolution: PreferredVideoResolution?, + sessionIds: List? = null, + ) { + sessionManager.session.value?.let { session -> + session.trackOverridesHandler.updateOverrides( + sessionIds = sessionIds, + dimensions = resolution?.let { VideoDimension(it.width, it.height) }, + ) + } + } + + fun setIncomingVideoEnabled(enabled: Boolean?, sessionIds: List? = null) { + sessionManager.session.value?.trackOverridesHandler?.updateOverrides( + sessionIds, + visible = enabled, + ) + } + + fun setIncomingAudioEnabled(enabled: Boolean, sessionIds: List? = null) { + val participantTrackMap = sessionManager.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..472ee2a227b --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt @@ -0,0 +1,99 @@ +/* + * 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 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 + * 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() { + 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() + + 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 + + /** 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 + + /** + * 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 + * 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 new file mode 100644 index 00000000000..37b383b3db6 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt @@ -0,0 +1,95 @@ +/* + * 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.CallState +import io.getstream.video.android.core.CallStatsReport +import kotlinx.coroutines.CoroutineScope +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 type: String, + private val id: String, + private val scope: CoroutineScope, + private val sessionManager: CallSessionManager, + private val state: CallState, +) { + private val logger by taggedLogger("Call:StatsReporter:$type:$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 = scope.launch { + // Wait a bit before we start capturing stats + delay(reportingIntervalMs) + + while (isActive) { + delay(reportingIntervalMs) + sessionManager.session.value?.sendCallStats( + report = collectStats(), + ) + } + } + } + + fun stop() { + callStatsReportingJob?.cancel() + } + + suspend fun collectStats(): CallStatsReport { + val currentSession = sessionManager.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) + 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/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/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/CallTestSeams.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt new file mode 100644 index 00000000000..337718bf3ea --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt @@ -0,0 +1,97 @@ +/* + * 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?) { + 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. + */ +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 + return field.get(this) as CallSessionManager +} + +/** + * 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 new file mode 100644 index 00000000000..79197027a93 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallApiClientTest.kt @@ -0,0 +1,355 @@ +/* + * 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.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 +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 +import io.mockk.coEvery +import io.mockk.coVerify +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 callRegistry: ClientCallRegistry + private lateinit var sessionManager: CallSessionManager + private lateinit var apiClient: CallApiClient + + @Before + fun setup() { + 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. + 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( + type = "default", + id = "call-id", + state = state, + clientImpl = clientImpl, + scope = testScope, + callSessionId = { "session-id" }, + callRegistry = callRegistry, + callAnalytics = mockk(relaxed = true), + sessionManager = sessionManager, + ) + } + + @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) + + verify { callRegistry.markRinging() } + verify { callRegistry.registerOutgoingRing() } + } + + @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 `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()) + } + verify { callRegistry.registerOutgoingRing() } + } + + @Test + fun `accept marks the call accepted on this device`() = runTest(testDispatcher) { + apiClient.accept() + verify { state.acceptedOnThisDevice = true } + verify { callRegistry.markAccepted() } + } + + @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, + ) + } + } + + @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/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..4fd4f43ba7e --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallConnectivityMonitorTest.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.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, 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 { + + 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 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) + + 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 { state.connection } returns connectionFlow + } + + 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") + 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 triggers a rejoin`() = runTest( + testDispatcher, + ) { + val listener = listenerOf(monitor()) + + listener.onConnected() + advanceUntilIdle() + + coVerify { + reconnector.reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + any(), + ) + } + } + + @Test + fun `reconnection soon after a disconnect triggers a fast reconnect`() = runTest( + testDispatcher, + ) { + val listener = listenerOf(monitor()) + + listener.onDisconnected() + listener.onConnected() + advanceUntilIdle() + + coVerify { + reconnector.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 { lifecycle.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) { 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 new file mode 100644 index 00000000000..6897a4dd58a --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallEventManagerTest.kt @@ -0,0 +1,142 @@ +/* + * 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.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.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 reconnector = mockk(relaxed = true) + + private fun manager() = CallEventManager("default", "call-id", testScope, reconnector = { + reconnector + }) + + @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 a migrate on GoAwayEvent`() = runTest(testDispatcher) { + val manager = manager() + + manager.handleEvent(mockk(relaxed = true)) + advanceUntilIdle() + + coVerify(exactly = 1) { reconnector.migrate() } + } + + @Test + fun `handleEvent ignores unrelated events`() = runTest(testDispatcher) { + val manager = manager() + + manager.handleEvent(mockk(relaxed = true)) + advanceUntilIdle() + + 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 new file mode 100644 index 00000000000..b108dfe4093 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallIceConnectionMonitorTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.components + +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 sessionManager: CallSessionManager + + @Before + fun setup() { + session = mockk(relaxed = true) + publisher = mockk(relaxed = true) + subscriber = mockk(relaxed = true) + 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, sessionManager) + + @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..b1fec5161ac --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -0,0 +1,262 @@ +/* + * 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.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.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.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.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +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.Rule +import org.junit.Test + +/** + * 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 { + + @get:Rule + val dispatcherRule = DispatcherRule() + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + 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 mockJoinResponse: JoinCallResponse + + @Before + fun setup() { + 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) + + sessionFlow = MutableStateFlow(null) + connectionFlow = MutableStateFlow(RealtimeConnection.InProgress) + + 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() { + unmockkAll() + } + + 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 { + apiClient.joinRequest( + any(), any(), any(), any(), any(), any(), any(), any(), + ) + } returns result + } + + @Test + fun `join succeeds and returns the connected session`() = runTest(testDispatcher) { + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Success + + val result = coordinator().join() + advanceUntilIdle() + + assertThat(result).isInstanceOf(Success::class.java) + assertThat((result as Success).value).isSameInstanceAs(mockSession) + verify { sessionMonitor.monitorSession(mockJoinResponse) } + } + + @Test + fun `join fails permanently on a terminal SFU failure without retrying`() = runTest( + testDispatcher, + ) { + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Failure( + Exception("permanent auth error"), + cause = SfuConnectFailureCause.TerminalSocketFailure, + ) + + val result = coordinator().join() + advanceUntilIdle() + + assertThat(result).isInstanceOf(Failure::class.java) + assertThat(connectionFlow.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. + stubJoinCall( + Failure(Error.ThrowableError("Unable to resolve host \"sfu\"", Exception("dns"))), + ) + + val result = coordinator().join() + advanceUntilIdle() + + assertThat(result).isInstanceOf(Failure::class.java) + // The join request is issued once per retry (3 attempts total). + coVerify(exactly = 3) { + apiClient.joinRequest( + any(), + any(), + any(), + any(), + any(), + any(), + any(), + any(), + ) + } + } + + @Test + fun `join fails when the call is already joined`() = runTest(testDispatcher) { + sessionFlow.value = mockk(relaxed = true) + + 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 { clientImpl.getCachedLocation() } returns + Failure(Error.GenericError("no location")) + + val result = coordinator().joinInternal( + 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) { + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Success + coEvery { apiClient.ring(any()) } returns + Success(mockk(relaxed = true)) + + val result = coordinator().joinAndRing(members = listOf("u1")) + advanceUntilIdle() + + assertThat(result).isInstanceOf(Success::class.java) + coVerify { apiClient.ring(any()) } + } + + @Test + fun `joinAndRing leaves the call when ringing fails`() = runTest(testDispatcher) { + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Success + coEvery { apiClient.ring(any()) } returns + Failure(Error.GenericError("ring failed")) + + val result = coordinator().joinAndRing(members = listOf("u1")) + advanceUntilIdle() + + assertThat(result).isInstanceOf(Failure::class.java) + coVerify { lifecycle.leave(any()) } + } +} 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..094997ff883 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.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.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.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 kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +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 [MediaManagerFactory] 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 sessionManager: CallSessionManager + private lateinit var mediaManager: MediaManagerImpl + + @Before + fun setup() { + clientImpl = mockk(relaxed = true) + state = mockk(relaxed = true) + sessionManager = mockk(relaxed = true) + mediaManager = mockk(relaxed = true) + every { sessionManager.session } returns MutableStateFlow(null) + } + + private fun manager() = CallMediaManager( + type = "default", + id = "call-id", + clientImpl = clientImpl, + scope = testScope, + state = state, + sessionManager = sessionManager, + eglBase = { mockk(relaxed = true) }, + mediaManagerFactory = MediaManagerFactory { _, _ -> mediaManager }, + ) + + @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) + } + + @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..90d008d4c5f --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallReconnectorTest.kt @@ -0,0 +1,261 @@ +/* + * 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.result.Result.Success +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 +import io.mockk.coEvery +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.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 lateinit var clientImpl: StreamVideoClient + private lateinit var state: CallState + private lateinit var connectionFlow: MutableStateFlow + private lateinit var sessionManager: CallSessionManager + private lateinit var sessionMonitor: SessionMonitor + private lateinit var lifecycle: CallLifecycleManager + private lateinit var statsReporter: CallStatsReporter + private lateinit var apiClient: CallApiClient + 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) + sessionMonitor = mockk(relaxed = true) + lifecycle = mockk(relaxed = true) + statsReporter = mockk(relaxed = true) + apiClient = 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 { lifecycle.isDestroyed } returns false + every { + clientImpl.coordinatorConnectionModule.networkStateProvider.isConnected() + } returns true + every { state.connection } returns connectionFlow + every { state._connection } returns connectionFlow + } + + private fun reconnector() = CallReconnector( + clientImpl = clientImpl, + sessionManager = sessionManager, + sessionFactory = sessionFactory, + lifecycle = lifecycle, + apiClient = apiClient, + state = state, + callAnalytics = callAnalytics, + statsReporter = statsReporter, + sessionMonitor = { sessionMonitor }, + type = "default", + id = "call-id", + ) + + @Test + fun `reconnect is skipped when the call is destroyed`() = runTest(testDispatcher) { + every { lifecycle.isDestroyed } returns true + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + "test", + ) + advanceUntilIdle() + + // No leave / failure driven when we bail out immediately. + verify(exactly = 0) { lifecycle.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) { lifecycle.leave(any()) } + } + + @Test + fun `disconnect strategy leaves the call`() = runTest(testDispatcher) { + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_DISCONNECT, + "server-disconnect", + ) + advanceUntilIdle() + + verify { lifecycle.leave(any()) } + } + + @Test + fun `rejoin without a location gives up and leaves`() = runTest(testDispatcher) { + sessionManager.location = null + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + "rejoin", + ) + advanceUntilIdle() + + assertThat(connectionFlow.value) + .isInstanceOf(RealtimeConnection.ReconnectingFailed::class.java) + verify { lifecycle.leave(any()) } + } + + @Test + fun `fast reconnect without a session gives up and leaves`() = runTest(testDispatcher) { + sessionManager.setActiveSession(null) + + reconnector().reconnect( + WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + "fast", + ) + advanceUntilIdle() + + assertThat(connectionFlow.value) + .isInstanceOf(RealtimeConnection.ReconnectingFailed::class.java) + } + + @Test + fun `strategy helpers forward to reconnect without throwing`() = runTest(testDispatcher) { + val reconnector = reconnector() + reconnector.fastReconnect("helper") + reconnector.rejoin("helper") + 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(sessionManager.session.value).isSameInstanceAs(newSession) + verify { sessionMonitor.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 { lifecycle.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(sessionManager.session.value).isSameInstanceAs(newSession) + coVerify { oldSession.finalizeMigration() } + verify { sessionMonitor.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, + ) { + 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 { + apiClient.joinRequest(any(), any(), any(), any(), any(), any(), any(), any()) + } returns Success(joinResponse) + 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 new file mode 100644 index 00000000000..3b0b568f90a --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRendererTest.kt @@ -0,0 +1,179 @@ +/* + * 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.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 +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 + * visibility / track-dimension and incoming media-quality updates to the session. + */ +class CallRendererTest { + + private val sessionManager = CallSessionManager() + + private fun renderer() = CallRenderer( + type = "default", + id = "call-id", + scope = mockk(relaxed = true), + sessionManager = sessionManager, + callAnalytics = mockk(relaxed = true), + eglBase = { mockk(relaxed = true) }, + callSessionId = { "call-id" }, + ) + + @Test + fun `setVisibility updates track dimensions with default dimension`() { + val session = mockk(relaxed = true) + sessionManager.setActiveSession(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) + sessionManager.setActiveSession(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`() { + sessionManager.setActiveSession(null) + // Should not throw. + renderer().setVisibility("s1", TrackType.TRACK_TYPE_VIDEO, visible = false) + } + + @Test + fun `setPreferredIncomingVideoResolution forwards overrides`() { + val session = mockk(relaxed = true) + sessionManager.setActiveSession(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) + sessionManager.setActiveSession(session) + + renderer().setPreferredIncomingVideoResolution(null) + + verify { + session.trackOverridesHandler.updateOverrides( + sessionIds = null, + dimensions = null, + ) + } + } + + @Test + fun `setIncomingVideoEnabled forwards visibility overrides`() { + val session = mockk(relaxed = true) + sessionManager.setActiveSession(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) + sessionManager.setActiveSession(session) + + // No tracks available -> should return without throwing. + renderer().setIncomingAudioEnabled(enabled = true) + } + + @Test + fun `setIncomingAudioEnabled toggles audio for all participants`() { + val audioTrack = mockk(relaxed = true) + sessionManager.setActiveSession(sessionWithAudioTrack(audioTrack)) + + renderer().setIncomingAudioEnabled(enabled = false) + + verify { audioTrack.enableAudio(false) } + } + + @Test + fun `setIncomingAudioEnabled toggles audio for the requested sessions`() { + val audioTrack = mockk(relaxed = true) + sessionManager.setActiveSession(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) + } + } +} 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..23be4570bd3 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallSessionManagerTest.kt @@ -0,0 +1,132 @@ +/* + * 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.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 fun manager() = CallSessionManager() + + @Test + fun `session starts empty and can be replaced via setActiveSession`() { + val manager = manager() + assertThat(manager.session.value).isNull() + + val session = mockk(relaxed = true) + manager.setActiveSession(session) + + assertThat(manager.session.value).isSameInstanceAs(session) + + manager.setActiveSession(null) + assertThat(manager.session.value).isNull() + } + + @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) + } + + @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() + 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 2ea125779a1..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,10 +16,13 @@ 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.internal.network.NetworkStateProvider +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 import io.mockk.coEvery import io.mockk.every import io.mockk.mockk @@ -35,42 +38,14 @@ 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 field = Call::class.java.getDeclaredField("network\$delegate") - field.isAccessible = true - field.set(this, lazyOf(mockNetwork)) - } - - @Suppress("UNCHECKED_CAST") - private fun Call.getFailedSfuIds(): MutableSet { - val field = Call::class.java.getDeclaredField("failedSfuIds") - field.isAccessible = true - return field.get(this) as MutableSet - } - - private fun Call.invokeAddFailedSfuId(sfuId: String) { - val method = Call::class.java.getDeclaredMethod("addFailedSfuId", String::class.java) - method.isAccessible = true - method.invoke(this, sfuId) - } - - private fun Call.invokeGetFailedSfuIdsSnapshot(): List { - val method = Call::class.java.getDeclaredMethod("getFailedSfuIdsSnapshot") - method.isAccessible = true - @Suppress("UNCHECKED_CAST") - return method.invoke(this) 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")) @@ -80,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 @@ -138,22 +113,22 @@ class FailedSfuIdsTest : IntegrationTestBase(connectCoordinatorWS = false) { emptyList(), emptyList(), ) - call.session.value = sessionMock - call.location = "test-location" + call.injectSession(sessionMock) + call.injectLocation("test-location") 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) @@ -163,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/reconnect/ReconnectAttemptsCountTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectAttemptsCountTest.kt index c963c7f0aab..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 @@ -16,12 +16,13 @@ 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.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. @@ -43,14 +44,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 field = Call::class.java.getDeclaredField("network\$delegate") - field.isAccessible = true - field.set(this, lazyOf(mockNetwork)) - } - private fun stubSessionForReconnect(sessionMock: RtcSession) { coEvery { sessionMock.getPublisherStats() } returns null coEvery { sessionMock.getSubscriberStats() } returns null @@ -70,7 +63,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() @@ -79,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()}", ) } @@ -92,7 +85,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, @@ -101,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()}", ) } @@ -112,13 +105,13 @@ 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, "first", ) - val attemptsAfterFirst = call.nonFastReconnectAttempts + val attemptsAfterFirst = call.nonFastReconnectAttempts() call.reconnect( WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, @@ -126,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/reconnect/ReconnectSessionIdTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/reconnect/ReconnectSessionIdTest.kt index b5378fcaa08..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,14 +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 field = Call::class.java.getDeclaredField("network\$delegate") - field.isAccessible = true - field.set(this, lazyOf(mockNetwork)) - } - @Test fun `Rejoin creates a new session`() = runTest(UnconfinedTestDispatcher()) { val sessionMock = mockk(relaxed = true) @@ -79,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/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/JoinRecoverableFailureTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/JoinRecoverableFailureTest.kt index f29e68167ae..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 @@ -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,60 +76,65 @@ 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 apiClient: CallApiClient + 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"), - ), - ) + sessionManager = CallSessionManager() + apiClient = mockk(relaxed = true) + connectionFlow = MutableStateFlow(RealtimeConnection.InProgress) - // _join builds the JoinCallResponse via joinRequest; stub it out. + 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()) + apiClient.joinRequest( + 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 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 = apiClient, + 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( testDispatcher, @@ -135,17 +145,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) @@ -160,24 +168,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) @@ -193,8 +197,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 } 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,