From a4d882389db019e9d0bc75acaafc532e48e71a49 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 00:17:31 +0530 Subject: [PATCH 01/13] chore: checkpoint 1 Checked the refactor with 1. CallApiDelegate 2. CallRenderer 3. CallEventManager --- .../io/getstream/video/android/core/Call.kt | 335 ++++------------ .../getstream/video/android/core/CallState.kt | 157 ++++---- .../video/android/core/RealtimeConnection.kt | 58 +++ .../android/core/call/CallApiDelegate.kt | 335 ++++++++++++++++ .../core/call/CallConnectivityMonitor.kt | 60 +++ .../android/core/call/CallEventManager.kt | 70 ++++ .../core/call/CallIceConnectionMonitor.kt | 77 ++++ .../call/CallNetworkSubscriptionController.kt | 33 ++ .../video/android/core/call/CallRenderer.kt | 94 +++++ .../android/core/call/CallSessionManager.kt | 374 ++++++++++++++++++ .../android/core/call/CallSfuEventMonitor.kt | 57 +++ .../android/core/call/CallStatsReporter.kt | 73 ++++ .../coroutines/flows/RestartableStateFlow.kt | 103 +++++ .../scopes/RestartableProducerScope.kt | 61 +++ 14 files changed, 1545 insertions(+), 342 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/RealtimeConnection.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallApiDelegate.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallEventManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallIceConnectionMonitor.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallNetworkSubscriptionController.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallRenderer.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSfuEventMonitor.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallStatsReporter.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/flows/RestartableStateFlow.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/scopes/RestartableProducerScope.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 43b2b9a0b2..6662b0b1d8 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 @@ -49,7 +49,6 @@ 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 @@ -61,6 +60,10 @@ import io.getstream.result.Result.Failure import io.getstream.result.Result.Success import io.getstream.result.flatMap import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.call.CallApiDelegate +import io.getstream.video.android.core.call.CallEventManager +import io.getstream.video.android.core.call.CallRenderer +import io.getstream.video.android.core.call.CallSessionManager import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.audio.InputAudioFilter import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory @@ -69,15 +72,13 @@ 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.coroutines.scopes.RestartableProducerScope 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 @@ -93,7 +94,6 @@ import io.getstream.video.android.core.utils.RampValueUpAndDownHelper import io.getstream.video.android.core.utils.StreamSingleFlightProcessorImpl 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 @@ -108,12 +108,9 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine 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 @@ -123,7 +120,6 @@ import stream.video.sfu.models.WebsocketReconnectStrategy import java.util.Collections import java.util.UUID import java.util.concurrent.ConcurrentHashMap -import kotlin.coroutines.resume /** * How long do we keep trying to make a full-reconnect (once the SFU signalling WS went down) @@ -165,8 +161,10 @@ public class Call( internal val scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) + internal val restartableProducerScope = RestartableProducerScope() + /** The call state contains all state such as the participant list, reactions etc */ - val state = CallState(client, this, user, scope) + val state = CallState(client, this, user, restartableProducerScope) private val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } @@ -267,6 +265,26 @@ public class Call( _peerConnectionFactory = value } + val events = MutableSharedFlow(extraBufferCapacity = 150) + private val callRenderer = CallRenderer() + internal val sessionManager = CallSessionManager( + call = this, + clientImpl = clientImpl, + powerManager = powerManager, + testInstanceProvider = testInstanceProvider, + ) + + private val apiDelegate = CallApiDelegate( + clientImpl = clientImpl, + type = type, + id = id, + call = this, + screenShareProvider = { screenShare }, + setScreenTrackCallBack = { sessionManager.session?.setScreenShareTrack() }, + ) + internal val callEventManager = + CallEventManager(events, sessionManager, restartableProducerScope, { subscriptions }) + /** * Checks if the audioBitrateProfile has changed since the factory was created, * and recreates the factory if needed. This should only be called before joining. @@ -434,47 +452,17 @@ public class Call( 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 + return apiDelegate.create( + memberIds, + members, + custom, + settings, + startsAt, + team, + ring, + notify, + video, + ) } /** Update a call */ @@ -483,16 +471,7 @@ public class Call( 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 + return apiDelegate.update(custom, settingsOverride, startsAt) } suspend fun join( @@ -1018,31 +997,13 @@ 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 = apiDelegate.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 = apiDelegate.muteAllUsers(audio, video, screenShare) fun setVisibility( sessionId: String, @@ -1083,14 +1044,7 @@ public class Call( } fun handleEvent(event: VideoEvent) { - logger.v { "[call handleEvent] #sfu; event.type: ${event.getEventType()}" } - - when (event) { - is GoAwayEvent -> - scope.launch { - migrate() - } - } + callEventManager.handleEvent(event) } // TODO: review this @@ -1107,61 +1061,15 @@ 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?.updateTrackDimensions( - sessionId, - trackType, - true, - VideoDimension(width, height), - viewportId, - ) - } - onRendered(videoRenderer) - } - - 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?.updateTrackDimensions( - sessionId, - trackType, - true, - VideoDimension(width, height), - viewportId, - ) - } - } - }, - ) - } + ) = callRenderer.initRenderer( + videoRenderer, + sessionId, + trackType, + eglBase, + sessionManager.session, + onRendered, + viewportId, + ) /** * Enables the provided client capabilities. @@ -1185,24 +1093,9 @@ 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 = apiDelegate.goLive(startHls, startRecording, startTranscription) - return result - } - - suspend fun stopLive(): Result { - val result = clientImpl.stopLive(type, id) - result.onSuccess { state.updateFromResponse(it) } - return result - } + suspend fun stopLive(): Result = apiDelegate.stopLive() suspend fun sendCustomEvent(data: Map): Result { return clientImpl.sendCustomEvent(this.type, this.id, data) @@ -1233,29 +1126,13 @@ public class Call( fun startScreenSharing( mediaProjectionPermissionResultData: Intent, includeAudio: Boolean = false, - ) { - if (state.ownCapabilities.value.contains(OwnCapability.Screenshare)) { - session?.setScreenShareTrack() - screenShare.enable(mediaProjectionPermissionResultData, includeAudio = includeAudio) - } else { - logger.w { "Can't start screen sharing - user doesn't have wnCapability.Screenshare permission" } - } - } + ): Unit = apiDelegate.startScreenSharing(mediaProjectionPermissionResultData, includeAudio) - fun stopScreenSharing() { - screenShare.disable(fromUser = true) - } + fun stopScreenSharing(): Unit = apiDelegate.stopScreenSharing() - suspend fun startHLS(): Result { - return clientImpl.startBroadcasting(type, id) - .onSuccess { - state.updateFromResponse(it) - } - } + suspend fun startHLS(): Result = apiDelegate.startHLS() - suspend fun stopHLS(): Result { - return clientImpl.stopBroadcasting(type, id) - } + suspend fun stopHLS(): Result = apiDelegate.stopHLS() public fun subscribeFor( vararg eventTypes: Class, @@ -1329,8 +1206,6 @@ public class Call( return clientImpl.updateMembers(type, id, request) } - val events = MutableSharedFlow(extraBufferCapacity = 150) - fun fireEvent(event: VideoEvent) = synchronized(subscriptions) { subscriptions.forEach { sub -> if (!sub.isDisposed) { @@ -1447,32 +1322,14 @@ public class Call( 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 = apiDelegate.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) - } + ): Result = apiDelegate.muteUsers(userIds, audio, video, screenShare) @VisibleForTesting internal suspend fun joinRequest( @@ -1481,25 +1338,13 @@ public class Call( migratingFrom: String? = null, ring: Boolean = false, notify: Boolean = false, - ): Result { - 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, - ) - result.onSuccess { - state.updateFromResponse(it) - } - return result - } + ): Result = apiDelegate.joinRequest( + create, + location, + migratingFrom, + ring, + notify, + ) fun cleanup() { // monitor.stop() @@ -1536,13 +1381,7 @@ public class Call( return clientImpl.notify(type, id) } - suspend fun accept(): Result { - logger.d { "[accept] #ringing; no args, call_id:$id" } - state.acceptedOnThisDevice = true - - clientImpl.state.transitionToAcceptCall(this) - return clientImpl.accept(type, id) - } + suspend fun accept(): Result = apiDelegate.accept() /** * Should outlive both the call scope and the service scope and needs to be executed in the client-level scope. @@ -1571,43 +1410,9 @@ public class Call( 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) - } + ): Unit = apiDelegate.collectUserFeedback(rating, reason, custom) - track.video.addSink(screenshotSink) - } - } + suspend fun takeScreenshot(track: VideoTrack): Bitmap? = apiDelegate.takeScreenshot(track) fun isPinnedParticipant(sessionId: String): Boolean = state.pinnedParticipants.value.containsKey( diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index d62863b755..b64c514b4a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -83,9 +83,10 @@ import io.getstream.android.video.generated.models.UpdatedCallPermissionsEvent import io.getstream.android.video.generated.models.VideoEvent import io.getstream.log.taggedLogger import io.getstream.result.Result -import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.closedcaptions.ClosedCaptionManager import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings +import io.getstream.video.android.core.coroutines.flows.RestartableStateFlow +import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope import io.getstream.video.android.core.events.AudioLevelChangedEvent import io.getstream.video.android.core.events.CallEndedSfuEvent import io.getstream.video.android.core.events.ChangePublishQualityEvent @@ -165,44 +166,6 @@ import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration -@Stable -public sealed interface RealtimeConnection { - /** - * We start out in the PreJoin state. This is before call.join is called - */ - public data object PreJoin : RealtimeConnection - - /** - * Join is in progress - */ - public data object InProgress : RealtimeConnection - - /** - * We set the state to Joined as soon as the call state is available - */ - public data class Joined(val session: RtcSession) : - RealtimeConnection // joined, participant state is available, you can render the call. Video isn't ready yet - - /** - * True when the peer connections are ready - */ - public data object Connected : - RealtimeConnection // connected to RTC, able to receive and send video - - /** - * Reconnecting is true whenever Rtc isn't available and trying to recover - * If the subscriber peer connection breaks we'll reconnect - * If the publisher peer connection breaks we'll reconnect - * Also if the network provider from the OS says that internet is down we'll set it to reconnecting - */ - public data object Reconnecting : - RealtimeConnection // reconnecting to recover from temporary issues - - public data object Migrating : RealtimeConnection - public data class Failed(val error: Any) : RealtimeConnection // permanent failure - public data object Disconnected : RealtimeConnection // normal disconnect by the app -} - /** * The CallState class keeps all state for a call * It's available on every call object @@ -216,17 +179,32 @@ public sealed interface RealtimeConnection { * */ @Stable -public class CallState( +public class CallState internal constructor( private val client: StreamVideo, private val call: Call, private val user: User, - @InternalStreamVideoApi - val scope: CoroutineScope, + private val restartableProducerScope: RestartableProducerScope, ) { + @Deprecated( + "Do not use this constructor. CallState must not be constructed with CoroutineScope. " + + "It breaks call reusability and will be removed. Do not use this directly. Kept for binary compatibility.", + level = DeprecationLevel.ERROR, + ) + public constructor( + client: StreamVideo, + call: Call, + user: User, + scope: CoroutineScope, + ) : this(client, call, user, call.restartableProducerScope) + private val logger by taggedLogger("CallState") private var participantsVisibilityMonitor: Job? = null + @InternalStreamVideoApi + val scope: CoroutineScope + get() = call.scope + // Create a CallActions implementation that delegates to the Call object @InternalStreamVideoApi val callActions = object : CallActions { @@ -309,22 +287,14 @@ public class CallState( internal val _serverPins: MutableStateFlow> = MutableStateFlow(emptyMap()) - internal val _pinnedParticipants: StateFlow> = - combine(_localPins, _serverPins) { local, server -> - val combined = mutableMapOf() - combined.putAll(local) - combined.putAll(server) - combined.toMap().asIterable().associate { - Pair(it.key, it.value.at) - } - }.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + internal val _pinnedParticipants: StateFlow> /** * Pinned participants, combined value both from server and local pins. */ - val pinnedParticipants: StateFlow> = _pinnedParticipants + val pinnedParticipants: StateFlow> - val stats = CallStats(call, scope) + val stats = CallStats(call, restartableProducerScope) private val participantsUpdate = TaskSchedulerWithDebounce() private val participantsUpdateConfig = ScheduleConfig( @@ -415,12 +385,7 @@ public class CallState( val livestream: StateFlow = livestreamFlow.debounce(1000).stateIn(scope, SharingStarted.WhileSubscribed(10_000L), null) - private var _sortedParticipantsState = SortedParticipantsState( - scope, - call, - _participants, - _pinnedParticipants, - ) + private var _sortedParticipantsState: SortedParticipantsState /** * Sorted participants based on @@ -433,7 +398,7 @@ public class CallState( * * Debounced 100ms to avoid rapid changes */ - val sortedParticipants = _sortedParticipantsState.asFlow().debounce(100) + val sortedParticipants: Flow> /** * Update participant sorting order @@ -548,20 +513,7 @@ public class CallState( * * @see [liveDuration] */ - public val liveDurationInMs = flow { - while (currentCoroutineContext().isActive) { - delay(1000) - - val liveStartedAt = _session.value?.liveStartedAt - val liveEndedAt = _session.value?.liveEndedAt ?: OffsetDateTime.now() - - liveStartedAt?.let { - val duration = liveEndedAt.toInstant().toEpochMilli() - liveStartedAt.toInstant() - .toEpochMilli() - emit(duration) - } - } - }.distinctUntilChanged().stateIn(scope, SharingStarted.WhileSubscribed(10000L), null) + public val liveDurationInMs: StateFlow /** * How long the call has been live for, represented as [Duration], or null if the call hasn't been live yet. @@ -569,9 +521,7 @@ public class CallState( * * @see [liveDurationInMs] */ - public val liveDuration = liveDurationInMs.mapState { durationInMs -> - durationInMs?.takeIf { it >= 1000 }?.let { (it / 1000).toDuration(DurationUnit.SECONDS) } - } + public val liveDuration: StateFlow private val _egress: MutableStateFlow = MutableStateFlow(null) val egress: StateFlow = _egress @@ -714,6 +664,59 @@ public class CallState( internal var incomingNotificationData = IncomingNotificationData(emptyMap()) + init { + /** + * If we assign [_pinnedParticipants] at declaration line, then [restartableProducerScope] + * will be null. As val's are assigned before constructor code has run + */ + _pinnedParticipants = RestartableStateFlow( + emptyMap(), + combine(_localPins, _serverPins) { local, server -> + val combined = mutableMapOf() + combined.putAll(local) + combined.putAll(server) + combined.toMap().asIterable().associate { + Pair(it.key, it.value.at) + } + }, + restartableProducerScope, + ) + + pinnedParticipants = _pinnedParticipants + + _sortedParticipantsState = SortedParticipantsState( + scope, + call, + _participants, + _pinnedParticipants, + ) + + sortedParticipants = _sortedParticipantsState.asFlow().debounce(100) + + liveDurationInMs = RestartableStateFlow( + null, + flow { + while (currentCoroutineContext().isActive) { + delay(1000) + + val liveStartedAt = _session.value?.liveStartedAt + val liveEndedAt = _session.value?.liveEndedAt ?: OffsetDateTime.now() + + liveStartedAt?.let { + val duration = liveEndedAt.toInstant().toEpochMilli() - liveStartedAt.toInstant() + .toEpochMilli() + emit(duration) + } + } + }.distinctUntilChanged(), + restartableProducerScope, + ) + + liveDuration = liveDurationInMs.mapState { durationInMs -> + durationInMs?.takeIf { it >= 1000 }?.let { (it / 1000).toDuration(DurationUnit.SECONDS) } + } + } + fun handleEvent(event: VideoEvent) { logger.d { "[handleEvent] ${event::class.java.name.split(".").last()}" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/RealtimeConnection.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/RealtimeConnection.kt new file mode 100644 index 0000000000..6050ed8bb1 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/RealtimeConnection.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core + +import androidx.compose.runtime.Stable +import io.getstream.video.android.core.call.RtcSession + +@Stable +public sealed interface RealtimeConnection { + /** + * We start out in the PreJoin state. This is before call.join is called + */ + public data object PreJoin : RealtimeConnection + + /** + * Join is in progress + */ + public data object InProgress : RealtimeConnection + + /** + * We set the state to Joined as soon as the call state is available + */ + public data class Joined(val session: RtcSession) : + RealtimeConnection // joined, participant state is available, you can render the call. Video isn't ready yet + + /** + * True when the peer connections are ready + */ + public data object Connected : + RealtimeConnection // connected to RTC, able to receive and send video + + /** + * Reconnecting is true whenever Rtc isn't available and trying to recover + * If the subscriber peer connection breaks we'll reconnect + * If the publisher peer connection breaks we'll reconnect + * Also if the network provider from the OS says that internet is down we'll set it to reconnecting + */ + public data object Reconnecting : + RealtimeConnection // reconnecting to recover from temporary issues + + public data object Migrating : RealtimeConnection + public data class Failed(val error: Any) : RealtimeConnection // permanent failure + public data object Disconnected : RealtimeConnection // normal disconnect by the app +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallApiDelegate.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallApiDelegate.kt new file mode 100644 index 0000000000..00ae8c83c3 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallApiDelegate.kt @@ -0,0 +1,335 @@ +/* + * 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 + +import android.content.Intent +import android.graphics.Bitmap +import androidx.annotation.VisibleForTesting +import io.getstream.android.video.generated.models.AcceptCallResponse +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.MemberRequest +import io.getstream.android.video.generated.models.MuteUsersResponse +import io.getstream.android.video.generated.models.OwnCapability +import io.getstream.android.video.generated.models.StopLiveResponse +import io.getstream.android.video.generated.models.UpdateCallRequest +import io.getstream.android.video.generated.models.UpdateCallResponse +import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CreateCallOptions +import io.getstream.video.android.core.RingingState +import io.getstream.video.android.core.ScreenShareManager +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.call.video.YuvFrame +import io.getstream.video.android.core.model.MuteUsersData +import io.getstream.video.android.core.model.QueriedMembers +import io.getstream.video.android.core.model.SortField +import io.getstream.video.android.core.model.VideoTrack +import io.getstream.video.android.core.utils.toQueriedMembers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import org.threeten.bp.OffsetDateTime +import org.webrtc.VideoSink +import kotlin.coroutines.resume + +internal class CallApiDelegate( + private val clientImpl: StreamVideoClient, + private val type: String, + private val id: String, + private val call: Call, + private val screenShareProvider: () -> ScreenShareManager, + private val setScreenTrackCallBack: () -> Unit, +) { + private val logger by taggedLogger("CallApiDelegate call:$type:$id") + + suspend fun get(): Result { + return clientImpl.getCall(type, id) + } + + 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 { + call.state.updateFromResponse(it) + if (ring) { + clientImpl.state.addRingingCall(call, RingingState.Outgoing()) + } + } + return response + } + + suspend fun update( + custom: Map? = null, + settingsOverride: CallSettingsRequest? = null, + startsAt: OffsetDateTime? = null, + ): Result { + val request = UpdateCallRequest( + custom = custom, + settingsOverride = settingsOverride, + startsAt = startsAt, + ) + val response = clientImpl.updateCall(type, id, request) + response.onSuccess { + call.state.updateFromResponse(it) + } + return response + } + + 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 { call.state.updateFromResponse(it) } + + return result + } + + suspend fun stopLive(): Result { + val result = clientImpl.stopLive(type, id) + result.onSuccess { call.state.updateFromResponse(it) } + return result + } + + /** + * User needs to have [OwnCapability.Screenshare] capability in order to start screen + * sharing. + * + * @param mediaProjectionPermissionResultData - intent data returned from the + * activity result after asking for screen sharing permission by launching + * MediaProjectionManager.createScreenCaptureIntent(). + * See https://developer.android.com/guide/topics/large-screens/media-projection#recommended_approach + */ + fun startScreenSharing( + mediaProjectionPermissionResultData: Intent, + includeAudio: Boolean = false, + ) { + if (call.state.ownCapabilities.value.contains(OwnCapability.Screenshare)) { + setScreenTrackCallBack.invoke() + screenShareProvider.invoke().enable( + mediaProjectionPermissionResultData, + includeAudio = includeAudio, + ) + } else { + logger.w { "Can't start screen sharing - user doesn't have wnCapability.Screenshare permission" } + } + } + + fun stopScreenSharing() { + screenShareProvider.invoke().disable(fromUser = true) + } + + suspend fun startHLS(): Result { + return clientImpl.startBroadcasting(type, id) + .onSuccess { + call.state.updateFromResponse(it) + } + } + + suspend fun stopHLS(): Result { + return clientImpl.stopBroadcasting(type, id) + } + + suspend fun accept(): Result { + logger.d { "[accept] #ringing; no args, call_id:$id" } + call.state.acceptedOnThisDevice = true + + clientImpl.state.removeRingingCall(call) + clientImpl.state.maybeStopForegroundService(call = call) + return clientImpl.accept(type, id) + } + + fun collectUserFeedback( + rating: Int, + reason: String? = null, + custom: Map? = null, + ) { + call.scope.launch { + clientImpl.collectFeedback( + callType = type, + id = id, + sessionId = call.sessionId, + rating = rating, + reason = reason, + custom = custom, + ) + } + } + + suspend fun takeScreenshot(track: VideoTrack): Bitmap? { + return suspendCancellableCoroutine { continuation -> + var screenshotSink: VideoSink? = null + screenshotSink = VideoSink { + // make sure we stop after first frame is delivered + if (!continuation.isActive) { + return@VideoSink + } + it.retain() + val bitmap = YuvFrame.bitmapFromVideoFrame(it) + it.release() + + // This has to be launched asynchronously - removing the sink on the + // same thread as the videoframe is delivered will lead to a deadlock + // (needs investigation why) + call.scope.launch { + track.video.removeSink(screenshotSink) + } + continuation.resume(bitmap) + } + + track.video.addSink(screenshotSink) + } + } + + 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 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 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 { call.state.updateFromResponse(it) }.map { it.toQueriedMembers() } + } + + @VisibleForTesting + internal suspend fun joinRequest( + create: CreateCallOptions? = null, + location: String, + migratingFrom: String? = null, + ring: Boolean = false, + notify: Boolean = false, + ): Result { + 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, + ) + result.onSuccess { + call.state.updateFromResponse(it) + } + return result + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt new file mode 100644 index 0000000000..29d1b253b4 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt @@ -0,0 +1,60 @@ +/* + * 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 + +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope +import io.getstream.video.android.core.internal.network.NetworkStateProvider +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal class CallConnectivityMonitor( + val callScope: RestartableProducerScope, + val state: CallConnectivityMonitorState, + val leaveAfterDisconnectSeconds: Long, + onFastReconnect: suspend () -> Unit, + onRejoin: suspend () -> Unit, + onDisconnected: suspend () -> Unit, + onLeaveTimeout: suspend () -> Unit, +) { + private val logger by taggedLogger("CallConnectivityMonitor") + private var leaveTimeoutAfterDisconnect: Job? = null + + internal val listener = object : NetworkStateProvider.NetworkStateListener { + override suspend fun onConnected() { + leaveTimeoutAfterDisconnect?.cancel() + val elapsedTimeMils = System.currentTimeMillis() - state.lastDisconnect + if (state.lastDisconnect > 0 && elapsedTimeMils < state.reconnectDeadlineMils) { + onFastReconnect() + } else { + onRejoin() + } + } + + override suspend fun onDisconnected() { + onDisconnected() + state.lastDisconnect = System.currentTimeMillis() + leaveTimeoutAfterDisconnect = callScope.launch { + delay(leaveAfterDisconnectSeconds * 1000) + onLeaveTimeout() + } + } + } +} + +internal data class CallConnectivityMonitorState(var lastDisconnect: Long = 0L, var reconnectDeadlineMils: Int = 10_000) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallEventManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallEventManager.kt new file mode 100644 index 0000000000..e2fb449f41 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallEventManager.kt @@ -0,0 +1,70 @@ +/* + * 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 + +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.coroutines.scopes.RestartableProducerScope +import io.getstream.video.android.core.events.GoAwayEvent +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlin.collections.forEach +import kotlin.let + +internal class CallEventManager( + private val events: MutableSharedFlow, + private val sessionManager: CallSessionManager, + private val callScope: RestartableProducerScope, + private val subscriptionsProvider: () -> Set, +) { + + private val logger by taggedLogger("CallEventManager") + + fun fireEvent(event: VideoEvent) = synchronized(subscriptionsProvider.invoke()) { + subscriptionsProvider.invoke().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]" } + } + } + + fun handleEvent(event: VideoEvent) { + logger.v { "[call handleEvent] #sfu; event.type: ${event.getEventType()}" } + + when (event) { + is GoAwayEvent -> + callScope.launch { + sessionManager.migrate() + } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallIceConnectionMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallIceConnectionMonitor.kt new file mode 100644 index 0000000000..5ff973b479 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallIceConnectionMonitor.kt @@ -0,0 +1,77 @@ +/* + * 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 + +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.webrtc.PeerConnection + +internal class CallIceConnectionMonitor( + private val scope: RestartableProducerScope, + private val sessionProvider: () -> RtcSession?, +) { + + private val logger by taggedLogger("CallIceConnectionMonitor") + + private var publisherJob: Job? = null + private var subscriberJob: Job? = null + + fun start() { + stop() + + publisherJob = scope.launch { + sessionProvider()?.publisher?.iceState?.collect { state -> + when (state) { + PeerConnection.IceConnectionState.FAILED, + PeerConnection.IceConnectionState.DISCONNECTED, + -> { + sessionProvider()?.publisher?.connection?.restartIce() + } + + else -> { + logger.d { "[publisher] ICE state = $state" } + } + } + } + } + + subscriberJob = scope.launch { + sessionProvider()?.subscriber?.iceState?.collect { state -> + when (state) { + PeerConnection.IceConnectionState.FAILED, + PeerConnection.IceConnectionState.DISCONNECTED, + -> { + sessionProvider()?.requestSubscriberIceRestart() + } + + else -> { + logger.d { "[subscriber] ICE state = $state" } + } + } + } + } + } + + fun stop() { + publisherJob?.cancel() + subscriberJob?.cancel() + publisherJob = null + subscriberJob = null + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallNetworkSubscriptionController.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallNetworkSubscriptionController.kt new file mode 100644 index 0000000000..ce8e35ac54 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallNetworkSubscriptionController.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 + +import io.getstream.video.android.core.internal.network.NetworkStateProvider + +internal class CallNetworkSubscriptionController( + private val network: NetworkStateProvider, + private val listener: NetworkStateProvider.NetworkStateListener, +) { + + fun start() { + network.subscribe(listener) + } + + fun stop() { + network.unsubscribe(listener) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallRenderer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallRenderer.kt new file mode 100644 index 0000000000..ae35266296 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallRenderer.kt @@ -0,0 +1,94 @@ +/* + * 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 + +import io.getstream.log.taggedLogger +import io.getstream.webrtc.android.ui.VideoTextureViewRenderer +import org.webrtc.EglBase +import org.webrtc.RendererCommon +import stream.video.sfu.models.TrackType +import stream.video.sfu.models.VideoDimension +import kotlin.getValue + +internal class CallRenderer { + + private val logger by taggedLogger("CallRenderer") + + internal fun initRenderer( + videoRenderer: VideoTextureViewRenderer, + sessionId: String, + trackType: TrackType, + eglBase: EglBase, + session: RtcSession?, + 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?.updateTrackDimensions( + sessionId, + trackType, + true, + VideoDimension(width, height), + viewportId, + ) + } + onRendered(videoRenderer) + } + + 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?.updateTrackDimensions( + sessionId, + trackType, + true, + VideoDimension(width, height), + viewportId, + ) + } + } + }, + ) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt new file mode 100644 index 0000000000..eca6cb969d --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt @@ -0,0 +1,374 @@ +/* + * 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 + +import android.annotation.SuppressLint +import android.os.PowerManager +import io.getstream.android.video.generated.models.JoinCallResponse +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.video.android.core.Call +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.model.toIceServer +import io.getstream.video.android.core.utils.StreamSingleFlightProcessorImpl +import stream.video.sfu.event.ReconnectDetails +import stream.video.sfu.models.WebsocketReconnectStrategy +import java.util.UUID +import kotlin.collections.map +import kotlin.let + +internal class CallSessionManager( + private val call: Call, + private val clientImpl: StreamVideoClient, + private val powerManager: PowerManager?, + private val testInstanceProvider: Call.Companion.TestInstanceProvider, + +) { + private val logger by taggedLogger("CallSessionManager") + + /** Session handles all real time communication for video and audio */ + internal var session: RtcSession? = null + internal var sessionId = UUID.randomUUID().toString() + + private var reconnectAttempts = 0 + internal var reconnectStartTime = 0L + internal var connectStartTime = 0L + + private val callConnectivityMonitorState = CallConnectivityMonitorState() + internal val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } + private val streamSingleFlightProcessorImpl = StreamSingleFlightProcessorImpl(call.scope) + private val callStatsReporter = CallStatsReporter(call) + private val callConnectivityMonitor = CallConnectivityMonitor( + call.restartableProducerScope, + callConnectivityMonitorState, + clientImpl.leaveAfterDisconnectSeconds, + { + fastReconnect("NetworkStateListener#onConnected") + }, + { + rejoin("NetworkStateListener#onConnected") + }, + { + call.state._connection.value = RealtimeConnection.Reconnecting + }, + { call.leave() }, + ) + + val sfuEventMonitor = + CallSfuEventMonitor( + call.restartableProducerScope, + { session }, + callConnectivityMonitorState, + ) + val iceConnectionMonitor = CallIceConnectionMonitor(call.restartableProducerScope, { session }) + val networkSubscriptionController = + CallNetworkSubscriptionController(network, callConnectivityMonitor.listener) + + @SuppressLint("VisibleForTests") + internal suspend fun _join( + create: Boolean = false, + createOptions: CreateCallOptions? = null, + ring: Boolean = false, + notify: Boolean = false, + ): Result { + reconnectAttempts = 0 + sfuEventMonitor.stop() + + if (session != null) { + return Failure(Error.GenericError("Call $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 + } + call.location = locationResult.value + + val result = call.joinRequest( + getOptions(create, createOptions), + locationResult.value, + ring = ring, + notify = notify, + ) + + if (result !is Success) { + return result as Failure + } + + try { + createJoinRtcSession(result.value) + } catch (e: Exception) { + return Failure(Error.GenericError(e.message ?: "RtcSession error occurred.")) + } + + clientImpl.state.setActiveCall(call) + monitorSession(result.value) + return Success(value = session!!) + } + + internal fun getOptions( + create: Boolean = false, + createOptions: CreateCallOptions? = null, + ): CreateCallOptions? { + return createOptions ?: if (create) { + CreateCallOptions() + } else { + null + } + } + + suspend fun fastReconnect(reason: String) = schedule("fast") { + logger.d { + "[fastReconnect] Reconnecting, reconnectAttempts:$reconnectAttempts" + } + session?.prepareReconnect() + call.state._connection.value = RealtimeConnection.Reconnecting + if (session != null) { + reconnectStartTime = System.currentTimeMillis() + + val session = session!! + val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() + val reconnectDetails = ReconnectDetails( + previous_session_id = prevSessionId, + strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, + announced_tracks = publishingInfo, + subscriptions = subscriptionsInfo, + reconnect_attempt = reconnectAttempts, + reason = reason, + ) + session.fastReconnect(reconnectDetails) + val oldSessionStats = callStatsReporter.collectStats(session) + session.sendCallStats(oldSessionStats) + } else { + logger.d { "[fastReconnect] [RealtimeConnection.Disconnected], call_id:${call.id}" } + call.state._connection.value = RealtimeConnection.Disconnected + } + } + + @SuppressLint("VisibleForTests") + internal suspend fun rejoin(reason: String) = schedule("rejoin") { + logger.d { "[rejoin] Rejoining" } + reconnectAttempts++ + call.state._connection.value = RealtimeConnection.Reconnecting + call.location?.let { + reconnectStartTime = System.currentTimeMillis() + + val joinResponse = call.joinRequest(location = it) + if (joinResponse is Success) { + replaceSession(joinResponse.value, reason) + } else { + logger.e { + "[rejoin] Failed to get a join response ${joinResponse.errorOrNull()}" + } + call.state._connection.value = RealtimeConnection.Reconnecting + } + } + } + internal fun monitorSession(result: JoinCallResponse) { + callStatsReporter.startCallStatsReporting( + session, + result.statsOptions.reportingIntervalMs.toLong(), + ) + sfuEventMonitor.start() + iceConnectionMonitor.start() + networkSubscriptionController.start() + } + + suspend fun migrate() = schedule("migrate") { + logger.d { "[migrate] Migrating" } + call.state._connection.value = RealtimeConnection.Migrating + call.location?.let { + reconnectStartTime = System.currentTimeMillis() + + val joinResponse = call.joinRequest(location = it) + if (joinResponse is Success) { + // switch to the new SFU + val cred = joinResponse.value.credentials + val session = this.session!! + val currentOptions = this.session?.publisher?.currentOptions() + val oldSfuUrl = session.sfuUrl + logger.i { "Rejoin SFU $oldSfuUrl to ${cred.server.url}" } + + this.sessionId = UUID.randomUUID().toString() + val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() + val reconnectDetails = ReconnectDetails( + previous_session_id = prevSessionId, + strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE, + announced_tracks = publishingInfo, + subscriptions = subscriptionsInfo, + from_sfu_id = oldSfuUrl, + reconnect_attempt = reconnectAttempts, + ) + session.prepareRejoin() + try { + val newSession = RtcSession( + clientImpl, + reconnectAttempts, + powerManager, + call, + sessionId, + clientImpl.apiKey, + clientImpl.coordinatorConnectionModule.lifecycle, + cred.server.url, + cred.server.wsEndpoint, + cred.token, + cred.iceServers.map { ice -> + ice.toIceServer() + }, + ) + val oldSession = this.session + this.session = newSession + this.session?.connect(reconnectDetails, currentOptions) + monitorSession(joinResponse.value) + oldSession?.leaveWithReason("migrating") + oldSession?.cleanup() + } catch (ex: Exception) { + logger.e(ex) { + "[switchSfu] Failed to join during " + + "migration - Error ${ex.message}" + } + call.state._connection.value = RealtimeConnection.Failed(ex) + } + } else { + logger.e { + "[switchSfu] Failed to get a join response during " + + "migration - falling back to reconnect. Error ${joinResponse.errorOrNull()}" + } + call.state._connection.value = RealtimeConnection.Reconnecting + } + } + } + + suspend fun createJoinRtcSession(result: JoinCallResponse) { + session = createJoinRtcSessionInner(result) + session?.let { call.state._connection.value = RealtimeConnection.Joined(it) } + session?.connect() + } + + fun createJoinRtcSessionInner(result: JoinCallResponse): RtcSession { + return if (testInstanceProvider.rtcSessionCreator != null) { + testInstanceProvider.rtcSessionCreator!!.invoke() + } else { + RtcSession( + sessionId = this.sessionId, + apiKey = clientImpl.apiKey, + lifecycle = clientImpl.coordinatorConnectionModule.lifecycle, + client = clientImpl, + call = call, + sfuUrl = result.credentials.server.url, + sfuWsUrl = result.credentials.server.wsEndpoint, + sfuToken = result.credentials.token, + remoteIceServers = result.credentials.iceServers.map { it.toIceServer() }, + powerManager = powerManager, + ) + } + } + + fun createRejoinSession(joinResponse: JoinCallResponse): RtcSession { + val cred = joinResponse.credentials + return RtcSession( + clientImpl, + reconnectAttempts, + powerManager, + call, + sessionId, + clientImpl.apiKey, + clientImpl.coordinatorConnectionModule.lifecycle, + cred.server.url, + cred.server.wsEndpoint, + cred.token, + cred.iceServers.map { ice -> + ice.toIceServer() + }, + ) + } + + fun createReconnectDetails(session: RtcSession, reason: String): ReconnectDetails { + val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() + return ReconnectDetails( + previous_session_id = prevSessionId, + strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, + announced_tracks = publishingInfo, + subscriptions = subscriptionsInfo, + reconnect_attempt = reconnectAttempts, + reason = reason, + ) + } + + suspend fun replaceSession(joinResponse: JoinCallResponse, reason: String) { + // switch to the new SFU + val cred = joinResponse.credentials + val oldSession = this.session!! + val oldSessionStats = callStatsReporter.collectStats(session) + val currentOptions = this.session?.publisher?.currentOptions() + logger.i { "Rejoin SFU ${oldSession?.sfuUrl} to ${cred.server.url}" } + + this.sessionId = UUID.randomUUID().toString() + val (prevSessionId, _, _) = oldSession.currentSfuInfo() + val reconnectDetails = createReconnectDetails(oldSession, reason) + call.state.removeParticipant(prevSessionId) + oldSession.prepareRejoin() + try { + this.session = createRejoinSession(joinResponse) + this.session?.connect(reconnectDetails, currentOptions) + this.session?.sfuTracer?.trace("rejoin", reason) + oldSession.sendCallStats(oldSessionStats) + oldSession.leaveWithReason("Rejoin :: $reason") + oldSession.cleanup() + monitorSession(joinResponse) + } catch (ex: Exception) { + logger.e(ex) { + "[rejoin] Failed to join response with ex: ${ex.message}" + } + call.state._connection.value = RealtimeConnection.Failed(ex) + } + } + + private suspend fun schedule(key: String, block: suspend () -> Unit) { + logger.d { "[schedule] #reconnect; no args" } + streamSingleFlightProcessorImpl.run(key, block) + } + + fun cleanup() { + session?.cleanup() + session = null + } + + fun cleanupMonitor() { + iceConnectionMonitor.stop() + sfuEventMonitor.stop() + } + + fun cleanupNetworkMonitoring() { + networkSubscriptionController.stop() + } + + fun reset() { + this.sessionId = UUID.randomUUID().toString() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSfuEventMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSfuEventMonitor.kt new file mode 100644 index 0000000000..79fdde5a7e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSfuEventMonitor.kt @@ -0,0 +1,57 @@ +/* + * 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 + +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope +import io.getstream.video.android.core.events.JoinCallResponseEvent +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +internal class CallSfuEventMonitor( + private val scope: RestartableProducerScope, + private val sessionProvider: () -> RtcSession?, + private val connectivityState: CallConnectivityMonitorState, +) { + + private val logger by taggedLogger("CallSfuEventMonitor") + + private var sfuEventsJob: Job? = null + + fun start() { + stop() + + sfuEventsJob = scope.launch { + sessionProvider()?.socket?.events()?.collect { event -> + if (event is JoinCallResponseEvent) { + connectivityState.reconnectDeadlineMils = + event.fastReconnectDeadlineSeconds * 1000 + + logger.d { + "[SFU] reconnect deadline = " + + "${connectivityState.reconnectDeadlineMils / 1000}s" + } + } + } + } + } + + fun stop() { + sfuEventsJob?.cancel() + sfuEventsJob = null + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallStatsReporter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallStatsReporter.kt new file mode 100644 index 0000000000..130d7c47c8 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallStatsReporter.kt @@ -0,0 +1,73 @@ +/* + * 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 + +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CallStatsReport +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.collections.plus +import kotlin.collections.takeLast + +internal class CallStatsReporter(private val call: Call) { + + private var callStatsReportingJob: Job? = null + + internal suspend fun collectStats(session: RtcSession?): CallStatsReport { + val publisherStats = session?.getPublisherStats() + val subscriberStats = session?.getSubscriberStats() + call.state.stats.updateFromRTCStats(publisherStats, isPublisher = true) + call.state.stats.updateFromRTCStats(subscriberStats, isPublisher = false) + call.state.stats.updateLocalStats() + val local = call.state.stats._local.value + + val report = CallStatsReport( + publisher = publisherStats, + subscriber = subscriberStats, + local = local, + stateStats = call.state.stats, + ) + + call.statsReport.value = report + call.statLatencyHistory.value + report.stateStats.publisher.latency.value + if (call.statLatencyHistory.value.size > 20) { + call.statLatencyHistory.value = call.statLatencyHistory.value.takeLast(20) + } + return report + } + + internal fun startCallStatsReporting(session: RtcSession?, reportingIntervalMs: Long = 10_000) { + cancelJobs() + callStatsReportingJob = call.scope.launch { + // Wait a bit before we start capturing stats + delay(reportingIntervalMs) + + while (isActive) { + delay(reportingIntervalMs) + session?.sendCallStats( + report = collectStats(session), + ) + } + } + } + + internal fun cancelJobs() { + callStatsReportingJob?.cancel() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/flows/RestartableStateFlow.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/flows/RestartableStateFlow.kt new file mode 100644 index 0000000000..6cb30f56e9 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/flows/RestartableStateFlow.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.coroutines.flows + +import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * A [StateFlow] implementation whose upstream collection can be safely restarted + * when the underlying call coroutine scope is cancelled and recreated. + * + * ## Why this exists + * The standard `stateIn(scope, ...)` operator permanently binds upstream collection + * to a single [CoroutineScope]. When that scope is cancelled (for example after + * `call.leave()`), the StateFlow stops updating forever and cannot be restarted. + * + * In this SDK, call- and participant-level state must survive leave/join cycles, + * while the call coroutine scope is intentionally cancelled and recreated. + * + * ## How this replaces `stateIn` + * Instead of tying upstream collection to a fixed scope, [RestartableStateFlow] + * separates: + * - **State storage** (a stable [StateFlow] exposed to clients) + * - **Producer lifecycle** (a coroutine collecting the upstream Flow) + * + * The producer coroutine is started and restarted via [RestartableProducerScope] + * whenever a new call scope is attached, while the StateFlow instance itself + * remains stable. + * + * ## Example + * + * ### ❌ Using `stateIn` (unsafe with reusable calls) + * ```kotlin + * val duration: StateFlow = + * durationInMs + * .map { it?.toDuration(DurationUnit.SECONDS) } + * .stateIn(call.scope, SharingStarted.WhileSubscribed(), null) + * ``` + * When `call.scope` is cancelled, `duration` stops updating permanently. + * + * ### ✅ Using [RestartableStateFlow] (safe) + * ```kotlin + * val duration: StateFlow = + * RestartableStateFlow( + * initialValue = null, + * upstream = durationInMs.map { it?.toDuration(DurationUnit.SECONDS) }, + * scope = restartableProducerScope + * ) + * ``` + * When the call scope is recreated, upstream collection is restarted automatically + * and `duration` continues emitting values without requiring resubscription. + * + * ## Key guarantees + * - The exposed [StateFlow] instance never changes + * - Existing collectors do not need to resubscribe + * - Upstream collection is safely restarted across call lifecycle changes + * + * This class is intended for internal use where state must outlive coroutine scopes. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class RestartableStateFlow( + initialValue: T, + upstream: Flow, + scope: RestartableProducerScope, +) : StateFlow { + + private val state = MutableStateFlow(initialValue) + + init { + scope.onAttach { realScope -> + realScope.launch { + upstream.collect { value -> + state.value = value + } + } + } + } + + override val value: T get() = state.value + override val replayCache: List get() = state.replayCache + override suspend fun collect(collector: FlowCollector): Nothing { + state.collect(collector) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/scopes/RestartableProducerScope.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/scopes/RestartableProducerScope.kt new file mode 100644 index 0000000000..2abb75fb18 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/scopes/RestartableProducerScope.kt @@ -0,0 +1,61 @@ +/* + * 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.coroutines.scopes + +import io.getstream.video.android.core.internal.InternalStreamVideoApi +import kotlinx.coroutines.CoroutineScope +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * A lifecycle-aware producer scope used to run long-lived or state-producing coroutines + * that must survive call leave/join cycles. + * + * Unlike a regular [CoroutineScope], this scope does not represent a fixed lifecycle. + * Instead, it forwards coroutine execution to the currently active call scope and + * allows producers to be explicitly restarted when the call scope is recreated. + * + * This is required to safely reuse call- and participant-level state after `call.leave()`, + * where the original coroutine scope is cancelled and replaced. + * + * This parameter is intended for internal use only. + */ +@InternalStreamVideoApi +internal class RestartableProducerScope : CoroutineScope { + + @Volatile + private var currentScope: CoroutineScope? = null + + private val onAttachCallbacks = mutableListOf<(CoroutineScope) -> Unit>() + + fun attach(scope: CoroutineScope) { + currentScope = scope + onAttachCallbacks.forEach { it(scope) } + } + + fun detach() { + currentScope = null + } + + override val coroutineContext: CoroutineContext + get() = currentScope?.coroutineContext ?: EmptyCoroutineContext + + fun onAttach(block: (CoroutineScope) -> Unit) { + onAttachCallbacks += block + currentScope?.let { block(it) } // start immediately if already attached + } +} From 650c1122e392b61d3ff47b98f49da5822a71c053 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 01:15:13 +0530 Subject: [PATCH 02/13] chore: checkpoint 2, working first join --- .../api/stream-video-android-core.api | 2 + .../io/getstream/video/android/core/Call.kt | 161 +++++-------- .../android/core/call/CallJoinContract.kt | 32 +++ .../android/core/call/CallJoinCoordinator.kt | 158 +++++++++++++ .../android/core/call/CallMediaManager.kt | 211 ++++++++++++++++++ .../android/core/call/CallReInitializer.kt | 118 ++++++++++ .../android/core/call/scope/ScopeProvider.kt | 6 + .../core/call/scope/ScopeProviderImpl.kt | 6 + 8 files changed, 595 insertions(+), 99 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinContract.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallMediaManager.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallReInitializer.kt diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index a8a2225420..5b3d993f06 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7608,6 +7608,8 @@ public final class io/getstream/video/android/core/Call { public final fun isVideoEnabled ()Z public final fun join (ZLio/getstream/video/android/core/CreateCallOptions;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun join$default (Lio/getstream/video/android/core/Call;ZLio/getstream/video/android/core/CreateCallOptions;ZZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun join1 (ZLio/getstream/video/android/core/CreateCallOptions;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun join1$default (Lio/getstream/video/android/core/Call;ZLio/getstream/video/android/core/CreateCallOptions;ZZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun joinAndRing (Ljava/util/List;Lio/getstream/video/android/core/CreateCallOptions;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun joinAndRing$default (Lio/getstream/video/android/core/Call;Ljava/util/List;Lio/getstream/video/android/core/CreateCallOptions;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun kickUser (Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; 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 6662b0b1d8..e008f6eb0c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt @@ -23,7 +23,6 @@ import android.os.PowerManager import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Stable import io.getstream.android.video.generated.models.AcceptCallResponse -import io.getstream.android.video.generated.models.AudioSettingsResponse import io.getstream.android.video.generated.models.BlockUserResponse import io.getstream.android.video.generated.models.CallSettingsRequest import io.getstream.android.video.generated.models.CallSettingsResponse @@ -52,7 +51,6 @@ import io.getstream.android.video.generated.models.UpdateCallMembersResponse 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 @@ -62,6 +60,9 @@ import io.getstream.result.flatMap import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.call.CallApiDelegate import io.getstream.video.android.core.call.CallEventManager +import io.getstream.video.android.core.call.CallJoinCoordinator +import io.getstream.video.android.core.call.CallMediaManager +import io.getstream.video.android.core.call.CallReInitializer import io.getstream.video.android.core.call.CallRenderer import io.getstream.video.android.core.call.CallSessionManager import io.getstream.video.android.core.call.RtcSession @@ -78,7 +79,6 @@ 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.PreferredVideoResolution import io.getstream.video.android.core.model.QueriedMembers import io.getstream.video.android.core.model.RejectReason @@ -120,6 +120,7 @@ import stream.video.sfu.models.WebsocketReconnectStrategy import java.util.Collections import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean /** * How long do we keep trying to make a full-reconnect (once the SFU signalling WS went down) @@ -152,7 +153,7 @@ public class Call( internal val scopeProvider: ScopeProvider = ScopeProviderImpl(clientImpl.scope) // Atomic controls - private var atomicLeave = AtomicUnitCall() + internal var atomicLeave = AtomicUnitCall() private val logger by taggedLogger("Call:$type:$id") private val supervisorJob = SupervisorJob() @@ -174,6 +175,17 @@ public class Call( val speaker by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.speaker } val screenShare by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.screenShare } + private val callMediaManager = CallMediaManager( + this, + { mediaManager }, + { camera }, + { microphone }, + { speaker }, + { screenShare }, + { _peerConnectionFactory }, + { _peerConnectionFactory = null }, + ) + /** The cid is type:id */ val cid = "$type:$id" @@ -226,7 +238,7 @@ public class Call( /** * Call has been left and the object is cleaned up and destroyed. */ - private var isDestroyed = false + internal var isDestroyed = AtomicBoolean(false) /** Session handles all real time communication for video and audio */ internal var session: RtcSession? = null @@ -285,6 +297,35 @@ public class Call( internal val callEventManager = CallEventManager(events, sessionManager, restartableProducerScope, { subscriptions }) + private val callReInitializer = CallReInitializer(clientImpl.scope) { + reInitialise() + } + + internal val callJoinCoordinator = CallJoinCoordinator( + call = this, + client = clientImpl, + callReInitializer = callReInitializer, + onJoinFail = { + sessionManager.session = null + }, + createJoinSession = { create, createOptions, ring, notify -> + sessionManager._join(create, createOptions, ring, notify) + }, + onRejoin = { reason -> sessionManager.rejoin(reason) }, + ) + + private fun reInitialise() { + logger.d { "[reInitialise]" } + sessionManager.reset() + state._connection.value = RealtimeConnection.Disconnected + atomicLeave = AtomicUnitCall() + scopeProvider.reset() + with(restartableProducerScope) { + detach() + attach(scope) + } + } + /** * Checks if the audioBitrateProfile has changed since the factory was created, * and recreates the factory if needed. This should only be called before joining. @@ -292,26 +333,8 @@ public class Call( * If the factory hasn't been created yet, it will be created with the current profile * when first accessed, so no recreation is needed. */ - internal fun ensureFactoryMatchesAudioProfile() { - val factory = _peerConnectionFactory - - // If factory hasn't been created yet, it will be created with current profile automatically - if (factory == null) { - return - } - - // Check if current profile differs from the profile used to create the factory - val factoryProfile = factory.audioBitrateProfile - val currentProfile = mediaManager.microphone.audioBitrateProfile.value - - if (factoryProfile != null && currentProfile != factoryProfile) { - logger.i { - "Audio bitrate profile changed from $factoryProfile to $currentProfile. " + - "Recreating factory before joining." - } - recreateFactoryAndAudioTracks() - } - } + internal fun ensureFactoryMatchesAudioProfile() = + callMediaManager.ensureFactoryMatchesAudioProfile() /** * Recreates peerConnectionFactory, audioSource, audioTrack, videoSource and videoTrack @@ -416,7 +439,6 @@ public class Call( private var monitorPublisherPCStateJob: Job? = null private var monitorSubscriberPCStateJob: Job? = null - private var sfuListener: Job? = null private var sfuEvents: Job? = null private val streamSingleFlightProcessorImpl = StreamSingleFlightProcessorImpl(scope) @@ -546,6 +568,13 @@ public class Call( return Failure(value = Error.GenericError(errorMessage)) } + suspend fun join1( + create: Boolean = false, + createOptions: CreateCallOptions? = null, + ring: Boolean = false, + notify: Boolean = false, + ): Result = callJoinCoordinator.join(create, createOptions, ring, notify) + suspend fun joinAndRing( members: List, createOptions: CreateCallOptions? = CreateCallOptions(members), @@ -584,7 +613,6 @@ public class Call( ): Result { reconnectAttepmts = 0 sfuEvents?.cancel() - sfuListener?.cancel() if (session != null) { return Failure(Error.GenericError("Call $cid has already been joined")) @@ -613,28 +641,8 @@ public class Call( 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 iceServers = result.value.credentials.iceServers.map { it.toIceServer() } try { - session = if (testInstanceProvider.rtcSessionCreator != null) { - testInstanceProvider.rtcSessionCreator!!.invoke() - } else { - RtcSession( - sessionId = this.sessionId, - apiKey = clientImpl.apiKey, - lifecycle = clientImpl.coordinatorConnectionModule.lifecycle, - client = client, - call = this, - sfuUrl = sfuUrl, - sfuWsUrl = sfuWsUrl, - sfuToken = sfuToken, - remoteIceServers = iceServers, - powerManager = powerManager, - ) - } - + session = sessionManager.createJoinRtcSessionInner(result.value) session?.let { state._connection.value = RealtimeConnection.Joined(it) } @@ -650,7 +658,6 @@ public class Call( private fun Call.monitorSession(result: JoinCallResponse) { sfuEvents?.cancel() - sfuListener?.cancel() startCallStatsReporting(result.statsOptions.reportingIntervalMs.toLong()) // listen to Signal WS sfuEvents = scope.launch { @@ -921,15 +928,14 @@ public class Call( session?.leaveWithReason("[reason=$reason, error=${disconnectionReason?.message}]") leaveTimeoutAfterDisconnect?.cancel() network.unsubscribe(listener) - sfuListener?.cancel() sfuEvents?.cancel() state._connection.value = RealtimeConnection.Disconnected logger.v { "[leave] #ringing; disconnectionReason: $disconnectionReason, call_id = $id" } - if (isDestroyed) { + if (isDestroyed.get()) { logger.w { "[leave] #ringing; Call already destroyed, ignoring" } return@atomicLeave } - isDestroyed = true + isDestroyed.set(true) sfuSocketReconnectionTime = null @@ -1255,41 +1261,8 @@ public class Call( }.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) - } + internal fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) { + callMediaManager.updateMediaManagerFromSettings(callSettings) } /** @@ -1507,18 +1480,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?.subscriber?.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) = + callMediaManager.setIncomingAudioEnabled(sessionManager.session, enabled, sessionIds) @InternalStreamVideoApi public val debug = Debug(this) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinContract.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinContract.kt new file mode 100644 index 0000000000..7bb7bf97b3 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinContract.kt @@ -0,0 +1,32 @@ +/* + * 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 + +import io.getstream.result.Result +import io.getstream.video.android.core.CreateCallOptions + +internal interface CallJoinContract { + + suspend fun join( + create: Boolean, + createOptions: CreateCallOptions?, + ring: Boolean, + notify: Boolean, + ): Result + + suspend fun rejoin(reason: String) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt new file mode 100644 index 0000000000..4902353d16 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.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 + +import android.annotation.SuppressLint +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.video.android.core.Call +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.utils.AtomicUnitCall +import io.getstream.video.android.core.utils.StreamSingleFlightProcessorImpl +import kotlinx.coroutines.delay +import kotlin.collections.plusAssign + +private const val PERMISSION_ERROR = "\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" + +internal class CallJoinCoordinator( + private val call: Call, + private val client: StreamVideoClient, + private val callReInitializer: CallReInitializer, + private val onJoinFail: () -> Unit, + private val createJoinSession: suspend ( + create: Boolean, + createOptions: CreateCallOptions?, + ring: Boolean, + notify: Boolean, + ) -> Result, + private val onRejoin: suspend (reason: String) -> Unit, +) : CallJoinContract { + private val streamSingleFlightProcessorImpl = + StreamSingleFlightProcessorImpl(call.restartableProducerScope) + + private val logger by taggedLogger("CallJoinCoordinator") + + override suspend fun join( + create: Boolean, + createOptions: CreateCallOptions?, + ring: Boolean, + notify: Boolean, + ): Result { + logger.d { + "[join] #ringing; #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" + } + + with(callReInitializer) { + waitFromCleanup() + reinitialiseCoroutinesIfNeeded() + } + + // CRITICAL: Reset isDestroyed for new session + call.isDestroyed.set(false) + logger.d { "[join] isDestroyed reset to false for new session" } + + val permissionPass = + client.permissionCheck.checkAndroidPermissionsGroup(client.context, call) + // Check android permissions and log a warning to make sure developers requested adequate permissions prior to using the call. + if (!permissionPass.first) { + logger.w { PERMISSION_ERROR } + } + // if we are a guest user, make sure we wait for the token before running the join flow + client.guestUserJob?.await() + + // Ensure factory is created with the current audioBitrateProfile before joining + call.ensureFactoryMatchesAudioProfile() + + // the join flow should retry up to 3 times + // if the error is not permanent + // and fail immediately on permanent errors + call.state._connection.value = RealtimeConnection.InProgress + var retryCount = 0 + + var result: Result + + call.atomicLeave = AtomicUnitCall() + while (retryCount < 3) { + result = createJoinSession(create, createOptions, ring, notify) + 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 = call.state.settings.value + if (settings != null) { + call.updateMediaManagerFromSettings(settings) + } else { + logger.w { + "[join] Call settings were null - this should never happen after a call" + + "is joined. MediaManager will not be initialised with server settings." + } + } + return result + } + if (result is Failure) { + onJoinFail() +// session = null + logger.e { "Join failed with error $result" } + if (isPermanentError(result.value)) { + call.state._connection.value = RealtimeConnection.Failed(result.value) + return result + } else { + retryCount += 1 + } + } + delay(retryCount - 1 * 1000L) + } + return onJoinFailAfterAllRetries() + } + + private fun onJoinFailAfterAllRetries(): Result { + onJoinFail() +// session = null + val errorMessage = "Join failed after 3 retries" + call.state._connection.value = RealtimeConnection.Failed(errorMessage) + return Failure(value = io.getstream.result.Error.GenericError(errorMessage)) + } + + @SuppressLint("VisibleForTests") + override suspend fun rejoin(reason: String) = schedule("rejoin") { + logger.d { "[rejoin] Rejoining" } + onRejoin(reason) + } + + private suspend fun schedule(key: String, block: suspend () -> Unit) { + logger.d { "[schedule] #reconnect; no args" } + streamSingleFlightProcessorImpl.run(key, block) + } + internal fun isPermanentError(error: Any): Boolean { + if (error is Error.ThrowableError) { + if (error.message.contains("Unable to resolve host")) { + return false + } + } + return true + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallMediaManager.kt new file mode 100644 index 0000000000..e6709c9a11 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallMediaManager.kt @@ -0,0 +1,211 @@ +/* + * 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 + +import io.getstream.android.video.generated.models.AudioSettingsResponse +import io.getstream.android.video.generated.models.CallSettingsResponse +import io.getstream.android.video.generated.models.VideoSettingsResponse +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.CameraDirection +import io.getstream.video.android.core.CameraManager +import io.getstream.video.android.core.DeviceStatus +import io.getstream.video.android.core.MediaManagerImpl +import io.getstream.video.android.core.MicrophoneManager +import io.getstream.video.android.core.ScreenShareManager +import io.getstream.video.android.core.SpeakerManager +import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory +import io.getstream.video.android.core.model.AudioTrack +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import stream.video.sfu.models.TrackType +import kotlin.collections.find +import kotlin.collections.forEach +import kotlin.collections.mapNotNull +import kotlin.collections.toList +import kotlin.getValue +import kotlin.let + +internal class CallMediaManager( + private val call: Call, + private val mediaManagerProvider: () -> MediaManagerImpl, + private val cameraProvider: () -> CameraManager, + private val microphoneProvider: () -> MicrophoneManager, + private val speakerProvider: () -> SpeakerManager, + private val screenShareProvider: () -> ScreenShareManager, + private val peerConnectionFactoryProvider: () -> StreamPeerConnectionFactory?, + private val resetPeerConnectionFactory: () -> Unit, +) { + + private val logger by taggedLogger("CallMediaManager") + + /** + * Enables or disables the reception of incoming audio tracks for all or specified participants. + * + * This method allows selective control over whether the local client receives audio from remote participants. + * It's particularly useful in scenarios such as livestreams or group calls where the user may want to mute + * specific participants' audio without affecting the overall session. + * + * @param enabled `true` to enable (subscribe to) incoming audio, `false` to disable (unsubscribe from) it. + * @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. + */ + internal fun setIncomingAudioEnabled( + session: RtcSession?, + enabled: Boolean, + sessionIds: List? = null, + ) { + val participantTrackMap = session?.subscriber?.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) } + } + + internal fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) { + // Speaker + if (call.speaker.status.value is DeviceStatus.NotSelected) { + val enableSpeaker = + if (callSettings.video.cameraDefaultOn || call.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 + } + + call.speaker.setEnabled(enabled = enableSpeaker) + } + + monitorHeadset() + + // Camera + if (call.camera.status.value is DeviceStatus.NotSelected) { + val defaultDirection = + if (callSettings.video.cameraFacing == VideoSettingsResponse.CameraFacing.Front) { + CameraDirection.Front + } else { + CameraDirection.Back + } + call.camera.setDirection(defaultDirection) + call.camera.setEnabled(callSettings.video.cameraDefaultOn) + } + + // Mic + if (call.microphone.status.value == DeviceStatus.NotSelected) { + val enabled = callSettings.audio.micDefaultOn + call.microphone.setEnabled(enabled) + } + } + + private fun monitorHeadset() { + call.microphone.devices.onEach { availableDevices -> + logger.d { + "[monitorHeadset] new available devices, prev selected: ${call.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" } + call.microphone.select(bluetoothHeadset) + } else if (wiredHeadset != null) { + logger.d { "[monitorHeadset] wired headset found" } + call.microphone.select(wiredHeadset) + } else { + logger.d { "[monitorHeadset] no headset found" } + + call.microphone.nonHeadsetFallbackDevice?.let { deviceBeforeHeadset -> + logger.d { "[monitorHeadset] before device selected" } + call.microphone.select(deviceBeforeHeadset) + } + } + }.launchIn(call.scope) + } + + /** + * Checks if the audioBitrateProfile has changed since the factory was created, + * and recreates the factory if needed. This should only be called before joining. + * + * If the factory hasn't been created yet, it will be created with the current profile + * when first accessed, so no recreation is needed. + */ + internal fun ensureFactoryMatchesAudioProfile() { + val factory = peerConnectionFactoryProvider.invoke() + + // 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 = mediaManagerProvider.invoke().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. + */ + internal fun recreateFactoryAndAudioTracks() { + val wasMicrophoneEnabled = microphoneProvider.invoke().status.value is DeviceStatus.Enabled + val wasCameraEnabled = cameraProvider.invoke().status.value is DeviceStatus.Enabled + + // Dispose all tracks and sources first + mediaManagerProvider.invoke().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 + microphoneProvider.invoke().enable(fromUser = false) + } + if (wasCameraEnabled) { + // videoTrack will be recreated on next access, then we enable it + cameraProvider.invoke().enable(fromUser = false) + } + } + + /** + * Recreates peerConnectionFactory with the current audioBitrateProfile. + * This should only be called before the call is joined. + */ + internal fun recreatePeerConnectionFactory() { + peerConnectionFactoryProvider.invoke()?.dispose() + resetPeerConnectionFactory() + // Next access to peerConnectionFactory will recreate it with current profile + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallReInitializer.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallReInitializer.kt new file mode 100644 index 0000000000..832da2f062 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallReInitializer.kt @@ -0,0 +1,118 @@ +/* + * 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 + +import io.getstream.log.taggedLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.let +import kotlin.takeIf + +internal class CallReInitializer( + val clientScope: CoroutineScope, + val onReInitialise: () -> Unit, +) { + + private val logger by taggedLogger("CallConcurrencyManager") + internal val cleanupMutex = Mutex() + internal var cleanupJob: Job? = null + + internal var needsReinitialization = AtomicBoolean(false) // TODO Rahul should be atomic + + @Volatile + internal var currentSupervisorJob: Job = SupervisorJob() + + @Volatile + internal var currentScope: CoroutineScope = + CoroutineScope(clientScope.coroutineContext + currentSupervisorJob) + + internal suspend fun waitFromCleanup() { + val job = cleanupMutex.withLock { + cleanupJob?.takeIf { it.isActive } + } + job?.let { + logger.d { "[join] Waiting for cleanup job: $it" } + try { + withTimeout(5000) { it.join() } + logger.d { "[join] Cleanup complete" } + } catch (e: TimeoutCancellationException) { + logger.w { "[join] Cleanup timeout, proceeding anyway" } + } + + cleanupMutex.withLock { + if (cleanupJob == it) { + cleanupJob = null + } + } + } + } + + internal suspend fun reinitialiseCoroutinesIfNeeded() { + val needsReinit = cleanupMutex.withLock { + if (needsReinitialization.get()) { + needsReinitialization.set(false) + true + } else { + false + } + } + + if (needsReinit) { + reinitializeCoroutines() + } + } + + internal fun reinitializeCoroutines() { + synchronized(this) { + currentSupervisorJob = SupervisorJob() + currentScope = CoroutineScope( + clientScope.coroutineContext + currentSupervisorJob, + ) + onReInitialise() + } + } + + internal fun cleanupJobReference(newCleanupJob: Job) { + newCleanupJob.invokeOnCompletion { + currentScope.launch { + cleanupMutex.withLock { + if (newCleanupJob == cleanupJob) { + cleanupJob = null + logger.v { "[cleanupJobReference] Cleared job reference" } + } + } + } + } + } + + internal fun cleanupLockVars(newCleanupJob: Job) { + currentScope.launch { + cleanupMutex.withLock { + needsReinitialization.set(true) + cleanupJob = newCleanupJob // TODO Rahul, I cannot understand why it is assigned + logger.d { "[cleanupLockVars] Cleanup job assigned" } + } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProvider.kt index 28d7b31944..0bc011aecb 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProvider.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProvider.kt @@ -40,4 +40,10 @@ internal interface ScopeProvider { * Cleans up resources when the provider is no longer needed. */ fun cleanup() + + /** + * Resets the provider to allow reuse after cleanup. + * This clears the cleanup flag and allows executors to be recreated. + */ + fun reset() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderImpl.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderImpl.kt index 50a80c90ee..8f39f9829a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderImpl.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderImpl.kt @@ -97,4 +97,10 @@ internal class ScopeProviderImpl( executor?.shutdown() executor = null } + + override fun reset() { + logger.d { "Resetting ScopeProvider to allow reuse" } + isCleanedUp = false + // executor is already null after cleanup, will be recreated on next use + } } From e6c57c8ef0521ac70d7920454c2bd364f729d21e Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 02:06:57 +0530 Subject: [PATCH 03/13] chore: checkpoint 3, working first join --- .../io/getstream/video/android/core/Call.kt | 121 +++++++++++------- .../android/core/call/CallSessionManager.kt | 64 ++++----- 2 files changed, 106 insertions(+), 79 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index e008f6eb0c..f4dd314244 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 @@ -241,8 +241,8 @@ public class Call( internal var isDestroyed = AtomicBoolean(false) /** Session handles all real time communication for video and audio */ - internal var session: RtcSession? = null - var sessionId = UUID.randomUUID().toString() +// internal var session: RtcSession? = null +// var sessionId = UUID.randomUUID().toString() internal val unifiedSessionId = UUID.randomUUID().toString() internal var connectStartTime = 0L @@ -286,13 +286,18 @@ public class Call( testInstanceProvider = testInstanceProvider, ) + internal val session: RtcSession? + get() = sessionManager.session.get() + val sessionId: String + get() = sessionManager.sessionId.get() + private val apiDelegate = CallApiDelegate( clientImpl = clientImpl, type = type, id = id, call = this, screenShareProvider = { screenShare }, - setScreenTrackCallBack = { sessionManager.session?.setScreenShareTrack() }, + setScreenTrackCallBack = { sessionManager.session.get()?.setScreenShareTrack() }, ) internal val callEventManager = CallEventManager(events, sessionManager, restartableProducerScope, { subscriptions }) @@ -306,7 +311,7 @@ public class Call( client = clientImpl, callReInitializer = callReInitializer, onJoinFail = { - sessionManager.session = null + sessionManager.session.set(null) }, createJoinSession = { create, createOptions, ring, notify -> sessionManager._join(create, createOptions, ring, notify) @@ -505,6 +510,13 @@ public class Call( logger.d { "[join] #ringing; #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" } + with(callReInitializer) { + waitFromCleanup() + reinitialiseCoroutinesIfNeeded() + } + + isDestroyed.set(false) + 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. @@ -551,7 +563,7 @@ public class Call( return result } if (result is Failure) { - session = null + sessionManager.session.set(null) logger.e { "Join failed with error $result" } if (isPermanentError(result.value)) { state._connection.value = RealtimeConnection.Failed(result.value) @@ -562,7 +574,7 @@ public class Call( } delay(retryCount - 1 * 1000L) } - session = null + sessionManager.session.set(null) val errorMessage = "Join failed after 3 retries" state._connection.value = RealtimeConnection.Failed(errorMessage) return Failure(value = Error.GenericError(errorMessage)) @@ -614,7 +626,7 @@ public class Call( reconnectAttepmts = 0 sfuEvents?.cancel() - if (session != null) { + if (sessionManager.session.get() != null) { return Failure(Error.GenericError("Call $cid has already been joined")) } logger.d { @@ -630,36 +642,35 @@ public class Call( } location = locationResult.value - val options = createOptions - ?: if (create) { - CreateCallOptions() - } else { - null - } - val result = joinRequest(options, locationResult.value, ring = ring, notify = notify) + val result = + joinRequest( + sessionManager.getOptions(create), + locationResult.value, + ring = ring, + notify = notify, + ) if (result !is Success) { return result as Failure } try { - session = sessionManager.createJoinRtcSessionInner(result.value) - session?.let { - state._connection.value = RealtimeConnection.Joined(it) - } - - session?.connect() + val session = sessionManager.createJoinRtcSessionInner(result.value) + sessionManager.session.set(session) + state._connection.value = RealtimeConnection.Joined(session) + session.connect() } catch (e: Exception) { return Failure(Error.GenericError(e.message ?: "RtcSession error occurred.")) } client.state.setActiveCall(this) monitorSession(result.value) - return Success(value = session!!) + return Success(value = sessionManager.session.get()!!) } private fun Call.monitorSession(result: JoinCallResponse) { sfuEvents?.cancel() startCallStatsReporting(result.statsOptions.reportingIntervalMs.toLong()) // listen to Signal WS + val session = sessionManager.session.get() sfuEvents = scope.launch { session?.let { it.socket.events().collect { event -> @@ -710,7 +721,7 @@ public class Call( while (isActive) { delay(reportingIntervalMs) - session?.sendCallStats( + sessionManager.session.get()?.sendCallStats( report = collectStats(), ) } @@ -718,6 +729,7 @@ public class Call( } internal suspend fun collectStats(): CallStatsReport { + val session = sessionManager.session.get() val publisherStats = session?.getPublisherStats() val subscriberStats = session?.getSubscriberStats() state.stats.updateFromRTCStats(publisherStats, isPublisher = true) @@ -745,13 +757,14 @@ public class Call( * Fast reconnect to the same SFU with the same participant session. */ suspend fun fastReconnect(reason: String = "unknown") = schedule("fast") { + val session = sessionManager.session.get() logger.d { "[fastReconnect] Reconnecting, reconnectAttepmts:$reconnectAttepmts" } session?.prepareReconnect() this@Call.state._connection.value = RealtimeConnection.Reconnecting if (session != null) { reconnectStartTime = System.currentTimeMillis() - val session = session!! +// val session = session!! val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() val reconnectDetails = ReconnectDetails( previous_session_id = prevSessionId, @@ -774,6 +787,7 @@ public class Call( * Rejoin a call. Creates a new session and joins as a new participant. */ suspend fun rejoin(reason: String = "unknown") = schedule("rejoin") { + val session = sessionManager.session.get() logger.d { "[rejoin] Rejoining" } reconnectAttepmts++ state._connection.value = RealtimeConnection.Reconnecting @@ -784,12 +798,11 @@ public class Call( if (joinResponse is Success) { // switch to the new SFU val cred = joinResponse.value.credentials - val oldSession = this.session!! + val oldSession = session!! val oldSessionStats = collectStats() - val currentOptions = this.session?.publisher?.currentOptions() + val currentOptions = session?.publisher?.currentOptions() logger.i { "Rejoin SFU ${oldSession?.sfuUrl} to ${cred.server.url}" } - - this.sessionId = UUID.randomUUID().toString() + sessionManager.sessionId.set(UUID.randomUUID().toString()) val (prevSessionId, subscriptionsInfo, publishingInfo) = oldSession.currentSfuInfo() val reconnectDetails = ReconnectDetails( previous_session_id = prevSessionId, @@ -801,13 +814,14 @@ public class Call( ) this.state.removeParticipant(prevSessionId) oldSession.prepareRejoin() + try { - this.session = RtcSession( + val session = RtcSession( clientImpl, reconnectAttepmts, powerManager, this, - sessionId, + sessionManager.sessionId.get(), clientImpl.apiKey, clientImpl.coordinatorConnectionModule.lifecycle, cred.server.url, @@ -817,8 +831,9 @@ public class Call( ice.toIceServer() }, ) - this.session?.connect(reconnectDetails, currentOptions) - this.session?.sfuTracer?.trace("rejoin", reason) + this.sessionManager.session.set(session) + session.connect(reconnectDetails, currentOptions) + session.sfuTracer.trace("rejoin", reason) oldSession.sendCallStats(oldSessionStats) oldSession.leaveWithReason("Rejoin :: $reason") oldSession.cleanup() @@ -851,12 +866,12 @@ public class Call( if (joinResponse is Success) { // switch to the new SFU val cred = joinResponse.value.credentials - val session = this.session!! - val currentOptions = this.session?.publisher?.currentOptions() + val session = this.sessionManager.session.get()!! + val currentOptions = session.publisher?.currentOptions() val oldSfuUrl = session.sfuUrl logger.i { "Rejoin SFU $oldSfuUrl to ${cred.server.url}" } - - this.sessionId = UUID.randomUUID().toString() + val sessionId = UUID.randomUUID().toString() + sessionManager.sessionId.set(sessionId) val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() val reconnectDetails = ReconnectDetails( previous_session_id = prevSessionId, @@ -883,9 +898,9 @@ public class Call( ice.toIceServer() }, ) - val oldSession = this.session - this.session = newSession - this.session?.connect(reconnectDetails, currentOptions) + val oldSession = this.sessionManager.session.get() + this.sessionManager.session.set(newSession) + session.connect(reconnectDetails, currentOptions) monitorSession(joinResponse.value) oldSession?.leaveWithReason("migrating") oldSession?.cleanup() @@ -906,8 +921,6 @@ public class Call( } } - private var reconnectJob: Job? = null - private suspend fun schedule(key: String, block: suspend () -> Unit) { logger.d { "[schedule] #reconnect; no args" } @@ -925,7 +938,9 @@ public class Call( monitorPublisherPCStateJob?.cancel() monitorPublisherPCStateJob = null monitorSubscriberPCStateJob = null - session?.leaveWithReason("[reason=$reason, error=${disconnectionReason?.message}]") + sessionManager.session.get()?.leaveWithReason( + "[reason=$reason, error=${disconnectionReason?.message}]", + ) leaveTimeoutAfterDisconnect?.cancel() network.unsubscribe(listener) sfuEvents?.cancel() @@ -961,6 +976,7 @@ public class Call( clientImpl.scope.launch { safeCall { + val session = sessionManager.session.get() session?.sfuTracer?.trace( "leave-call", "[reason=$reason, error=${disconnectionReason?.message}]", @@ -1020,6 +1036,7 @@ public class Call( logger.i { "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" } + val session = sessionManager.session.get() session?.updateTrackDimensions( sessionId, trackType, @@ -1040,6 +1057,7 @@ public class Call( logger.i { "[setVisibility] #track; #sfu; viewportId: $viewportId, sessionId: $sessionId, trackType: $trackType, visible: $visible" } + val session = sessionManager.session.get() session?.updateTrackDimensions( sessionId, trackType, @@ -1072,7 +1090,7 @@ public class Call( sessionId, trackType, eglBase, - sessionManager.session, + sessionManager.session.get(), onRendered, viewportId, ) @@ -1320,12 +1338,13 @@ public class Call( ) fun cleanup() { + val session = sessionManager.session.get() // monitor.stop() session?.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 = null + sessionManager.session.set(null) // Cleanup the call's scope provider scopeProvider.cleanup() } @@ -1451,6 +1470,7 @@ public class Call( resolution: PreferredVideoResolution?, sessionIds: List? = null, ) { + val session = sessionManager.session.get() session?.let { session -> session.trackOverridesHandler.updateOverrides( sessionIds = sessionIds, @@ -1466,7 +1486,10 @@ public class Call( * @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?.trackOverridesHandler?.updateOverrides(sessionIds, visible = enabled) + sessionManager.session.get()?.trackOverridesHandler?.updateOverrides( + sessionIds, + visible = enabled, + ) } /** @@ -1481,7 +1504,7 @@ public class Call( * If `null`, the audio setting is applied to all participants currently in the session. */ fun setIncomingAudioEnabled(enabled: Boolean, sessionIds: List? = null) = - callMediaManager.setIncomingAudioEnabled(sessionManager.session, enabled, sessionIds) + callMediaManager.setIncomingAudioEnabled(sessionManager.session.get(), enabled, sessionIds) @InternalStreamVideoApi public val debug = Debug(this) @@ -1490,11 +1513,11 @@ public class Call( public class Debug(val call: Call) { public fun pause() { - call.session?.subscriber?.disable() + call.sessionManager.session.get()?.subscriber?.disable() } public fun resume() { - call.session?.subscriber?.enable() + call.sessionManager.session.get()?.subscriber?.enable() } public fun rejoin() { @@ -1504,11 +1527,11 @@ public class Call( } public fun restartSubscriberIce() { - call.session?.subscriber?.connection?.restartIce() + call.sessionManager.session.get()?.subscriber?.connection?.restartIce() } public fun restartPublisherIce() { - call.session?.publisher?.connection?.restartIce() + call.sessionManager.session.get()?.publisher?.connection?.restartIce() } fun migrate() { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt index eca6cb969d..ca38871930 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt @@ -18,6 +18,7 @@ package io.getstream.video.android.core.call import android.annotation.SuppressLint import android.os.PowerManager +import androidx.lifecycle.AtomicReference import io.getstream.android.video.generated.models.JoinCallResponse import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -35,6 +36,7 @@ import stream.video.sfu.models.WebsocketReconnectStrategy import java.util.UUID import kotlin.collections.map import kotlin.let +import kotlin.toString internal class CallSessionManager( private val call: Call, @@ -46,8 +48,8 @@ internal class CallSessionManager( private val logger by taggedLogger("CallSessionManager") /** Session handles all real time communication for video and audio */ - internal var session: RtcSession? = null - internal var sessionId = UUID.randomUUID().toString() + internal var session: AtomicReference = AtomicReference(null) + internal var sessionId: AtomicReference = AtomicReference(UUID.randomUUID().toString()) private var reconnectAttempts = 0 internal var reconnectStartTime = 0L @@ -76,10 +78,11 @@ internal class CallSessionManager( val sfuEventMonitor = CallSfuEventMonitor( call.restartableProducerScope, - { session }, + { session.get() }, callConnectivityMonitorState, ) - val iceConnectionMonitor = CallIceConnectionMonitor(call.restartableProducerScope, { session }) + val iceConnectionMonitor = + CallIceConnectionMonitor(call.restartableProducerScope, { session.get() }) val networkSubscriptionController = CallNetworkSubscriptionController(network, callConnectivityMonitor.listener) @@ -128,7 +131,7 @@ internal class CallSessionManager( clientImpl.state.setActiveCall(call) monitorSession(result.value) - return Success(value = session!!) + return Success(value = session.get()!!) } internal fun getOptions( @@ -146,12 +149,12 @@ internal class CallSessionManager( logger.d { "[fastReconnect] Reconnecting, reconnectAttempts:$reconnectAttempts" } - session?.prepareReconnect() + session.get()?.prepareReconnect() call.state._connection.value = RealtimeConnection.Reconnecting if (session != null) { reconnectStartTime = System.currentTimeMillis() - val session = session!! + val session = session.get()!! val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() val reconnectDetails = ReconnectDetails( previous_session_id = prevSessionId, @@ -191,7 +194,7 @@ internal class CallSessionManager( } internal fun monitorSession(result: JoinCallResponse) { callStatsReporter.startCallStatsReporting( - session, + session.get(), result.statsOptions.reportingIntervalMs.toLong(), ) sfuEventMonitor.start() @@ -209,12 +212,12 @@ internal class CallSessionManager( if (joinResponse is Success) { // switch to the new SFU val cred = joinResponse.value.credentials - val session = this.session!! - val currentOptions = this.session?.publisher?.currentOptions() + val session = this.session.get()!! + val currentOptions = this.session.get()?.publisher?.currentOptions() val oldSfuUrl = session.sfuUrl logger.i { "Rejoin SFU $oldSfuUrl to ${cred.server.url}" } - this.sessionId = UUID.randomUUID().toString() + this.sessionId.set(UUID.randomUUID().toString()) val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() val reconnectDetails = ReconnectDetails( previous_session_id = prevSessionId, @@ -231,7 +234,7 @@ internal class CallSessionManager( reconnectAttempts, powerManager, call, - sessionId, + sessionId.get(), clientImpl.apiKey, clientImpl.coordinatorConnectionModule.lifecycle, cred.server.url, @@ -241,9 +244,9 @@ internal class CallSessionManager( ice.toIceServer() }, ) - val oldSession = this.session - this.session = newSession - this.session?.connect(reconnectDetails, currentOptions) + val oldSession = this.session.get() + this.session.set(newSession) + newSession.connect(reconnectDetails, currentOptions) monitorSession(joinResponse.value) oldSession?.leaveWithReason("migrating") oldSession?.cleanup() @@ -264,10 +267,11 @@ internal class CallSessionManager( } } + // TODO Rahul, refactor before using suspend fun createJoinRtcSession(result: JoinCallResponse) { - session = createJoinRtcSessionInner(result) - session?.let { call.state._connection.value = RealtimeConnection.Joined(it) } - session?.connect() + session.set(createJoinRtcSessionInner(result)) + session.get()?.let { call.state._connection.value = RealtimeConnection.Joined(it) } + session.get()?.connect() } fun createJoinRtcSessionInner(result: JoinCallResponse): RtcSession { @@ -275,7 +279,7 @@ internal class CallSessionManager( testInstanceProvider.rtcSessionCreator!!.invoke() } else { RtcSession( - sessionId = this.sessionId, + sessionId = this.sessionId.get(), apiKey = clientImpl.apiKey, lifecycle = clientImpl.coordinatorConnectionModule.lifecycle, client = clientImpl, @@ -296,7 +300,7 @@ internal class CallSessionManager( reconnectAttempts, powerManager, call, - sessionId, + sessionId.get(), clientImpl.apiKey, clientImpl.coordinatorConnectionModule.lifecycle, cred.server.url, @@ -323,20 +327,20 @@ internal class CallSessionManager( suspend fun replaceSession(joinResponse: JoinCallResponse, reason: String) { // switch to the new SFU val cred = joinResponse.credentials - val oldSession = this.session!! - val oldSessionStats = callStatsReporter.collectStats(session) - val currentOptions = this.session?.publisher?.currentOptions() + val oldSession = this.session.get()!! + val oldSessionStats = callStatsReporter.collectStats(session.get()) + val currentOptions = this.session.get()?.publisher?.currentOptions() logger.i { "Rejoin SFU ${oldSession?.sfuUrl} to ${cred.server.url}" } - this.sessionId = UUID.randomUUID().toString() + this.sessionId.set(UUID.randomUUID().toString()) val (prevSessionId, _, _) = oldSession.currentSfuInfo() val reconnectDetails = createReconnectDetails(oldSession, reason) call.state.removeParticipant(prevSessionId) oldSession.prepareRejoin() try { - this.session = createRejoinSession(joinResponse) - this.session?.connect(reconnectDetails, currentOptions) - this.session?.sfuTracer?.trace("rejoin", reason) + this.session.set(createRejoinSession(joinResponse)) + this.session.get()?.connect(reconnectDetails, currentOptions) + this.session.get()?.sfuTracer?.trace("rejoin", reason) oldSession.sendCallStats(oldSessionStats) oldSession.leaveWithReason("Rejoin :: $reason") oldSession.cleanup() @@ -355,8 +359,8 @@ internal class CallSessionManager( } fun cleanup() { - session?.cleanup() - session = null + session.get()?.cleanup() + session.set(null) } fun cleanupMonitor() { @@ -369,6 +373,6 @@ internal class CallSessionManager( } fun reset() { - this.sessionId = UUID.randomUUID().toString() + this.sessionId.set(UUID.randomUUID().toString()) } } From 70d023bfde0a8742265a86908bb4e1d2b30cafc3 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 11:59:47 +0530 Subject: [PATCH 04/13] chore: checkpoint 4, working first join --- .../api/stream-video-android-core.api | 1 - .../io/getstream/video/android/core/Call.kt | 45 ++++++++++--------- .../android/core/call/CallJoinCoordinator.kt | 5 +-- .../android/core/call/CallSessionManager.kt | 19 ++++---- .../reconnect/ReconnectAttemptsCountTest.kt | 6 +-- 5 files changed, 36 insertions(+), 40 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 5b3d993f06..012b9cb5d2 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7651,7 +7651,6 @@ public final class io/getstream/video/android/core/Call { public static synthetic fun setIncomingVideoEnabled$default (Lio/getstream/video/android/core/Call;Ljava/lang/Boolean;Ljava/util/List;ILjava/lang/Object;)V public final fun setPreferredIncomingVideoResolution (Lio/getstream/video/android/core/model/PreferredVideoResolution;Ljava/util/List;)V public static synthetic fun setPreferredIncomingVideoResolution$default (Lio/getstream/video/android/core/Call;Lio/getstream/video/android/core/model/PreferredVideoResolution;Ljava/util/List;ILjava/lang/Object;)V - public final fun setSessionId (Ljava/lang/String;)V public final fun setVideoFilter (Lio/getstream/video/android/core/call/video/VideoFilter;)V public final fun setVisibility (Ljava/lang/String;Lstream/video/sfu/models/TrackType;ZLjava/lang/String;)V public final fun setVisibility (Ljava/lang/String;Lstream/video/sfu/models/TrackType;ZLjava/lang/String;II)V 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 f4dd314244..9dad65b22b 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 @@ -148,7 +148,6 @@ public class Call( internal var location: String? = null private var subscriptions = Collections.synchronizedSet(mutableSetOf()) - internal var reconnectAttepmts = 0 internal val clientImpl = client as StreamVideoClient internal val scopeProvider: ScopeProvider = ScopeProviderImpl(clientImpl.scope) @@ -245,8 +244,10 @@ public class Call( // var sessionId = UUID.randomUUID().toString() internal val unifiedSessionId = UUID.randomUUID().toString() - internal var connectStartTime = 0L - internal var reconnectStartTime = 0L + internal val connectStartTime: Long + get() = sessionManager.connectStartTime + internal val reconnectStartTime: Long + get() = sessionManager.reconnectStartTime /** * EGL base context shared between peerConnectionFactory and mediaManager @@ -290,6 +291,8 @@ public class Call( get() = sessionManager.session.get() val sessionId: String get() = sessionManager.sessionId.get() + internal val reconnectAttempts: Int + get() = sessionManager.reconnectAttempts private val apiDelegate = CallApiDelegate( clientImpl = clientImpl, @@ -501,7 +504,7 @@ public class Call( return apiDelegate.update(custom, settingsOverride, startsAt) } - suspend fun join( + suspend fun join1( create: Boolean = false, createOptions: CreateCallOptions? = null, ring: Boolean = false, @@ -580,7 +583,7 @@ public class Call( return Failure(value = Error.GenericError(errorMessage)) } - suspend fun join1( + suspend fun join( create: Boolean = false, createOptions: CreateCallOptions? = null, ring: Boolean = false, @@ -623,7 +626,7 @@ public class Call( ring: Boolean = false, notify: Boolean = false, ): Result { - reconnectAttepmts = 0 + sessionManager.reconnectAttempts = 0 sfuEvents?.cancel() if (sessionManager.session.get() != null) { @@ -633,7 +636,7 @@ public class Call( "[joinInternal] #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" } - connectStartTime = System.currentTimeMillis() + sessionManager.connectStartTime = System.currentTimeMillis() // step 1. call the join endpoint to get a list of SFUs val locationResult = clientImpl.getCachedLocation() @@ -654,10 +657,10 @@ public class Call( return result as Failure } try { - val session = sessionManager.createJoinRtcSessionInner(result.value) - sessionManager.session.set(session) - state._connection.value = RealtimeConnection.Joined(session) - session.connect() + val localSession = sessionManager.createJoinRtcSessionInner(result.value) + sessionManager.session.set(localSession) + state._connection.value = RealtimeConnection.Joined(localSession) + localSession.connect() } catch (e: Exception) { return Failure(Error.GenericError(e.message ?: "RtcSession error occurred.")) } @@ -758,11 +761,11 @@ public class Call( */ suspend fun fastReconnect(reason: String = "unknown") = schedule("fast") { val session = sessionManager.session.get() - logger.d { "[fastReconnect] Reconnecting, reconnectAttepmts:$reconnectAttepmts" } + logger.d { "[fastReconnect] Reconnecting, reconnectAttepmts:$reconnectAttempts" } session?.prepareReconnect() this@Call.state._connection.value = RealtimeConnection.Reconnecting if (session != null) { - reconnectStartTime = System.currentTimeMillis() + sessionManager.reconnectStartTime = System.currentTimeMillis() // val session = session!! val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() @@ -771,7 +774,7 @@ public class Call( strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, announced_tracks = publishingInfo, subscriptions = subscriptionsInfo, - reconnect_attempt = reconnectAttepmts, + reconnect_attempt = reconnectAttempts, reason = reason, ) session.fastReconnect(reconnectDetails) @@ -789,10 +792,10 @@ public class Call( suspend fun rejoin(reason: String = "unknown") = schedule("rejoin") { val session = sessionManager.session.get() logger.d { "[rejoin] Rejoining" } - reconnectAttepmts++ + sessionManager.reconnectAttempts++ state._connection.value = RealtimeConnection.Reconnecting location?.let { - reconnectStartTime = System.currentTimeMillis() + sessionManager.reconnectStartTime = System.currentTimeMillis() val joinResponse = joinRequest(location = it) if (joinResponse is Success) { @@ -809,7 +812,7 @@ public class Call( strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_REJOIN, announced_tracks = publishingInfo, subscriptions = subscriptionsInfo, - reconnect_attempt = reconnectAttepmts, + reconnect_attempt = reconnectAttempts, reason = reason, ) this.state.removeParticipant(prevSessionId) @@ -818,7 +821,7 @@ public class Call( try { val session = RtcSession( clientImpl, - reconnectAttepmts, + reconnectAttempts, powerManager, this, sessionManager.sessionId.get(), @@ -860,7 +863,7 @@ public class Call( logger.d { "[migrate] Migrating" } state._connection.value = RealtimeConnection.Migrating location?.let { - reconnectStartTime = System.currentTimeMillis() + sessionManager.reconnectStartTime = System.currentTimeMillis() val joinResponse = joinRequest(location = it) if (joinResponse is Success) { @@ -879,13 +882,13 @@ public class Call( announced_tracks = publishingInfo, subscriptions = subscriptionsInfo, from_sfu_id = oldSfuUrl, - reconnect_attempt = reconnectAttepmts, + reconnect_attempt = reconnectAttempts, ) session.prepareRejoin() try { val newSession = RtcSession( clientImpl, - reconnectAttepmts, + reconnectAttempts, powerManager, this, sessionId, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt index 4902353d16..e8367d3f58 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt @@ -73,7 +73,6 @@ internal class CallJoinCoordinator( // CRITICAL: Reset isDestroyed for new session call.isDestroyed.set(false) - logger.d { "[join] isDestroyed reset to false for new session" } val permissionPass = client.permissionCheck.checkAndroidPermissionsGroup(client.context, call) @@ -115,7 +114,6 @@ internal class CallJoinCoordinator( } if (result is Failure) { onJoinFail() -// session = null logger.e { "Join failed with error $result" } if (isPermanentError(result.value)) { call.state._connection.value = RealtimeConnection.Failed(result.value) @@ -131,10 +129,9 @@ internal class CallJoinCoordinator( private fun onJoinFailAfterAllRetries(): Result { onJoinFail() -// session = null val errorMessage = "Join failed after 3 retries" call.state._connection.value = RealtimeConnection.Failed(errorMessage) - return Failure(value = io.getstream.result.Error.GenericError(errorMessage)) + return Failure(value = Error.GenericError(errorMessage)) } @SuppressLint("VisibleForTests") diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt index ca38871930..0e6f3209d6 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt @@ -51,7 +51,8 @@ internal class CallSessionManager( internal var session: AtomicReference = AtomicReference(null) internal var sessionId: AtomicReference = AtomicReference(UUID.randomUUID().toString()) - private var reconnectAttempts = 0 + // TODO Rahul, these variables could be atomicInt or AtomicLong, not sure yet + internal var reconnectAttempts = 0 internal var reconnectStartTime = 0L internal var connectStartTime = 0L @@ -96,7 +97,7 @@ internal class CallSessionManager( reconnectAttempts = 0 sfuEventMonitor.stop() - if (session != null) { + if (session.get() != null) { return Failure(Error.GenericError("Call $call.cid has already been joined")) } logger.d { @@ -124,7 +125,10 @@ internal class CallSessionManager( } try { - createJoinRtcSession(result.value) + val localSession = createJoinRtcSessionInner(result.value) + session.set(localSession) + call.state._connection.value = RealtimeConnection.Joined(localSession) + localSession.connect() } catch (e: Exception) { return Failure(Error.GenericError(e.message ?: "RtcSession error occurred.")) } @@ -151,7 +155,7 @@ internal class CallSessionManager( } session.get()?.prepareReconnect() call.state._connection.value = RealtimeConnection.Reconnecting - if (session != null) { + if (session.get() != null) { reconnectStartTime = System.currentTimeMillis() val session = session.get()!! @@ -267,13 +271,6 @@ internal class CallSessionManager( } } - // TODO Rahul, refactor before using - suspend fun createJoinRtcSession(result: JoinCallResponse) { - session.set(createJoinRtcSessionInner(result)) - session.get()?.let { call.state._connection.value = RealtimeConnection.Joined(it) } - session.get()?.connect() - } - fun createJoinRtcSessionInner(result: JoinCallResponse): RtcSession { return if (testInstanceProvider.rtcSessionCreator != null) { testInstanceProvider.rtcSessionCreator!!.invoke() 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 db537078ea..816751e5c3 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 @@ -37,7 +37,7 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { // Rejoin call.rejoin() - assertEquals(1, call.reconnectAttepmts) + assertEquals(1, call.reconnectAttempts) } @Test @@ -49,7 +49,7 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { // Rejoin call.fastReconnect() - assertEquals(0, call.reconnectAttepmts) + assertEquals(0, call.reconnectAttempts) } @Test @@ -62,6 +62,6 @@ class ReconnectAttemptsCountTest : IntegrationTestBase() { // Rejoin call.rejoin() call.rejoin() - assertEquals(2, call.reconnectAttepmts) + assertEquals(2, call.reconnectAttempts) } } From ae18f26aa8b4c4c3bcf083670400ace1d6481401 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 13:38:11 +0530 Subject: [PATCH 05/13] chore: checkpoint 5, working first join --- .../io/getstream/video/android/core/Call.kt | 41 +++-- .../getstream/video/android/core/CallState.kt | 28 +-- .../android/core/call/CallCleanupManager.kt | 160 ++++++++++++++++++ .../android/core/call/CallSessionManager.kt | 10 +- 4 files changed, 209 insertions(+), 30 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallCleanupManager.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index 9dad65b22b..6dd28f96fe 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 @@ -59,12 +59,14 @@ import io.getstream.result.Result.Success import io.getstream.result.flatMap import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.call.CallApiDelegate +import io.getstream.video.android.core.call.CallCleanupManager import io.getstream.video.android.core.call.CallEventManager import io.getstream.video.android.core.call.CallJoinCoordinator import io.getstream.video.android.core.call.CallMediaManager import io.getstream.video.android.core.call.CallReInitializer import io.getstream.video.android.core.call.CallRenderer import io.getstream.video.android.core.call.CallSessionManager +import io.getstream.video.android.core.call.CallStatsReporter import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.audio.InputAudioFilter import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory @@ -87,8 +89,6 @@ 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.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.StreamSingleFlightProcessorImpl @@ -98,7 +98,6 @@ 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 @@ -155,16 +154,21 @@ public class Call( internal 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 val scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) - internal val restartableProducerScope = RestartableProducerScope() + private val callReInitializer = CallReInitializer(clientImpl.scope) { + reInitialise() + } + + internal val scope: CoroutineScope + get() = callReInitializer.currentScope + /** The call state contains all state such as the participant list, reactions etc */ - val state = CallState(client, this, user, restartableProducerScope) + val state = CallState(client, this, user, restartableProducerScope, { sessionManager }) private val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } @@ -283,7 +287,6 @@ public class Call( internal val sessionManager = CallSessionManager( call = this, clientImpl = clientImpl, - powerManager = powerManager, testInstanceProvider = testInstanceProvider, ) @@ -305,10 +308,6 @@ public class Call( internal val callEventManager = CallEventManager(events, sessionManager, restartableProducerScope, { subscriptions }) - private val callReInitializer = CallReInitializer(clientImpl.scope) { - reInitialise() - } - internal val callJoinCoordinator = CallJoinCoordinator( call = this, client = clientImpl, @@ -449,7 +448,16 @@ public class Call( private var monitorSubscriberPCStateJob: Job? = null private var sfuEvents: Job? = null private val streamSingleFlightProcessorImpl = StreamSingleFlightProcessorImpl(scope) - + private val callStatsReporter: CallStatsReporter = CallStatsReporter(this) + private val callCleanupManager = CallCleanupManager( + call = this, + sessionManager = sessionManager, + client = clientImpl, + mediaManagerProvider = { mediaManager }, + callReInitializer = callReInitializer, + clientScope = clientImpl.scope, + callStatsReporter = callStatsReporter, + ) init { scope.launch { soundInputProcessor.currentAudioLevel.collect { @@ -459,6 +467,7 @@ public class Call( powerManager = safeCallWithDefault(null) { clientImpl.context.getSystemService(POWER_SERVICE) as? PowerManager } + restartableProducerScope.attach(scope) } /** Basic crud operations */ @@ -1354,11 +1363,7 @@ public class Call( // 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() + callCleanupManager.shutDownJobsGracefully() } suspend fun ring(): Result { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index b64c514b4a..44adbecd8b 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -83,6 +83,7 @@ import io.getstream.android.video.generated.models.UpdatedCallPermissionsEvent import io.getstream.android.video.generated.models.VideoEvent import io.getstream.log.taggedLogger import io.getstream.result.Result +import io.getstream.video.android.core.call.CallSessionManager import io.getstream.video.android.core.closedcaptions.ClosedCaptionManager import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings import io.getstream.video.android.core.coroutines.flows.RestartableStateFlow @@ -184,6 +185,7 @@ public class CallState internal constructor( private val call: Call, private val user: User, private val restartableProducerScope: RestartableProducerScope, + private val sessionManager: () -> CallSessionManager, // Todo Rahul, can be made non-lambda ) { @Deprecated( @@ -196,14 +198,14 @@ public class CallState internal constructor( call: Call, user: User, scope: CoroutineScope, - ) : this(client, call, user, call.restartableProducerScope) + ) : this(client, call, user, call.restartableProducerScope, { call.sessionManager }) private val logger by taggedLogger("CallState") private var participantsVisibilityMonitor: Job? = null @InternalStreamVideoApi val scope: CoroutineScope - get() = call.scope + get() = restartableProducerScope // Create a CallActions implementation that delegates to the Call object @InternalStreamVideoApi @@ -221,15 +223,15 @@ public class CallState internal constructor( } override suspend fun pinParticipant(userId: String, sessionId: String) { - call.state.pin(userId, sessionId) + pin(userId, sessionId) } override suspend fun unpinParticipant(sessionId: String) { - call.state.unpin(sessionId) + unpin(sessionId) } override fun isLocalParticipant(sessionId: String): Boolean { - return sessionId == call.sessionId + return sessionId == sessionManager().sessionId.get() } } @@ -263,7 +265,9 @@ public class CallState internal constructor( /** Your own participant state */ public val me: StateFlow = _participants.mapState { map -> - map[call.sessionId] ?: participants.value.find { it.isLocal } + map[sessionManager.invoke().sessionId.get()] ?: participants.value.find { + it.isLocal + } // TODO Rahul, made this needs to go inside init {} } /** Your own participant state */ @@ -276,7 +280,9 @@ public class CallState internal constructor( /** participants other than yourself */ public val remoteParticipants: StateFlow> = - _participants.mapState { it.filterKeys { key -> key != call.sessionId }.values.toList() } + _participants.mapState { + it.filterKeys { key -> key != sessionManager.invoke().sessionId.get() }.values.toList() + } // TODO Rahul, made this needs to go inside init {} /** the dominant speaker */ private val _dominantSpeaker: MutableStateFlow = MutableStateFlow(null) @@ -826,7 +832,7 @@ public class CallState internal constructor( } is CallEndedEvent -> { - call.state.cancelTimeout() + cancelTimeout() updateFromResponse(event.call) _endedAt.value = OffsetDateTime.now(Clock.systemUTC()) _endedByUser.value = event.user?.toUser() @@ -976,7 +982,7 @@ public class CallState internal constructor( } is ChangePublishQualityEvent -> { - call.session?.handleEvent(event) + sessionManager().session.get()?.handleEvent(event) } is ErrorEvent -> { @@ -1187,9 +1193,9 @@ public class CallState internal constructor( scope.launch { val callServiceConfig = StreamVideo.instanceOrNull()?.state?.callConfigRegistry?.get(call.type) ?: CallServiceConfig() if (callServiceConfig.moderationConfig.videoModerationConfig.enable) { - call.state.moderationManager.applyVideoModeration() + moderationManager.applyVideoModeration() delay(callServiceConfig.moderationConfig.videoModerationConfig.blurDuration) - call.state.moderationManager.clearVideoModeration() + moderationManager.clearVideoModeration() } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallCleanupManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallCleanupManager.kt new file mode 100644 index 0000000000..0e73bab0bd --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallCleanupManager.kt @@ -0,0 +1,160 @@ +/* + * 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 + +import io.getstream.log.taggedLogger +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.MediaManagerImpl +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController +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.safeCall +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withLock +import kotlin.sequences.forEach + +/** + * Manages call cleanup + */ +internal class CallCleanupManager( + private val call: Call, + private val sessionManager: CallSessionManager, + private val client: StreamVideoClient, + private val callReInitializer: CallReInitializer, + private val mediaManagerProvider: () -> MediaManagerImpl, // ← Lambda provider + private val clientScope: CoroutineScope, + private val callStatsReporter: CallStatsReporter, +) { + private val logger by taggedLogger("CallLifecycleManager") + private val mediaManager: MediaManagerImpl by lazy { + mediaManagerProvider() + } + + fun leave(reason: String = "user") { + logger.d { "[leave] #ringing; no args, call_cid:${call.cid}" } + + callReInitializer.currentScope.launch { + val shouldProceed = !isCleanupInProgress() + if (shouldProceed) { + internalLeave(null, reason) + } + } + } + + private suspend fun isCleanupInProgress(): Boolean { + return callReInitializer.cleanupMutex.withLock { + val currentJob = callReInitializer.cleanupJob + if (currentJob?.isActive == true) { + logger.w { + "[isCleanupInProgress] Cleanup already in progress (job: $currentJob), " + + "ignoring duplicate leave call" + } + true + } else { + logger.v { "[isCleanupInProgress] No active cleanup, proceeding with leave" } + false + } + } + } + + private fun internalLeave(disconnectionReason: Throwable?, reason: String) = call.atomicLeave { + sessionManager.cleanupMonitor() + + // Leave session + sessionManager.session.get()?.leaveWithReason( + "[reason=$reason, error=${disconnectionReason?.message}]", + ) + + // Cancel network monitoring + sessionManager.cleanupNetworkMonitoring() + + // Update connection state + call.state._connection.value = RealtimeConnection.Disconnected + + logger.v { "[leave] #ringing; disconnectionReason: $disconnectionReason, call_id = ${call.id}" } + if (call.isDestroyed.get()) { + logger.w { "[leave] #ringing; Call already destroyed, ignoring" } + return@atomicLeave + } + call.isDestroyed.set(true) + + /** + * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) + */ + call.stopScreenSharing() + mediaManager.camera.disable() + mediaManager.microphone.disable() + + if (call.id == client.state.activeCall.value?.id) { + client.state.removeActiveCall(call) // Will also stop CallService + } + + if (call.id == client.state.ringingCall.value?.id) { + client.state.removeRingingCall(call) + } + + TelecomCallController(client.context) + .leaveCall(call) + + client.onCallCleanUp(call) + + val newCleanupJob = client.scope.launch { + safeCall { + sessionManager.session.get()?.sfuTracer?.trace( + "leave-call", + "[reason=$reason, error=${disconnectionReason?.message}]", + ) + val stats = callStatsReporter.collectStats(sessionManager.session.get()) + sessionManager.session.get()?.sendCallStats(stats) + } + cleanup() + } + with(callReInitializer) { + cleanupJobReference(newCleanupJob) + cleanupLockVars(newCleanupJob) + } + } + + internal fun cleanup() { + logger.d { "[cleanup] Starting cleanup" } + + sessionManager.cleanup() + shutDownJobsGracefully() + callStatsReporter.cancelJobs() + + // Access mediaManager through lazy provider + cleanupMedia() + call.scopeProvider.cleanup() + logger.d { "[cleanup] Cleanup complete" } + } + + fun cleanupMedia() { + mediaManager.cleanup() + } + + internal fun shutDownJobsGracefully() { + UserScope(ClientScope()).launch { + callReInitializer.currentSupervisorJob.children.forEach { it.join() } + callReInitializer.currentSupervisorJob.cancel() + } + callReInitializer.currentScope.cancel() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt index 0e6f3209d6..764e3f689e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt @@ -17,6 +17,7 @@ package io.getstream.video.android.core.call import android.annotation.SuppressLint +import android.content.Context.POWER_SERVICE import android.os.PowerManager import androidx.lifecycle.AtomicReference import io.getstream.android.video.generated.models.JoinCallResponse @@ -31,6 +32,7 @@ import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.model.toIceServer import io.getstream.video.android.core.utils.StreamSingleFlightProcessorImpl +import io.getstream.video.android.core.utils.safeCallWithDefault import stream.video.sfu.event.ReconnectDetails import stream.video.sfu.models.WebsocketReconnectStrategy import java.util.UUID @@ -41,11 +43,11 @@ import kotlin.toString internal class CallSessionManager( private val call: Call, private val clientImpl: StreamVideoClient, - private val powerManager: PowerManager?, private val testInstanceProvider: Call.Companion.TestInstanceProvider, ) { private val logger by taggedLogger("CallSessionManager") + private var powerManager: PowerManager? = null /** Session handles all real time communication for video and audio */ internal var session: AtomicReference = AtomicReference(null) @@ -87,6 +89,12 @@ internal class CallSessionManager( val networkSubscriptionController = CallNetworkSubscriptionController(network, callConnectivityMonitor.listener) + init { + powerManager = safeCallWithDefault(null) { + clientImpl.context.getSystemService(POWER_SERVICE) as? PowerManager + } + } + @SuppressLint("VisibleForTests") internal suspend fun _join( create: Boolean = false, From ae3f5fb12444fb24bede0a9f6a627bd480f0bb28 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 14:48:07 +0530 Subject: [PATCH 06/13] chore: checkpoint 6, working first join --- .../api/stream-video-android-core.api | 2 + .../io/getstream/video/android/core/Call.kt | 10 +- .../video/android/core/MediaManager.kt | 135 ++++++++++++------ .../video/android/core/StreamVideoClient.kt | 2 +- .../android/core/call/CallCleanupManager.kt | 32 +++-- .../android/core/call/CallSessionManager.kt | 4 - 6 files changed, 121 insertions(+), 64 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 012b9cb5d2..615be6d4ea 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7616,6 +7616,8 @@ public final class io/getstream/video/android/core/Call { public static synthetic fun kickUser$default (Lio/getstream/video/android/core/Call;Ljava/lang/String;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun leave (Ljava/lang/String;)V public static synthetic fun leave$default (Lio/getstream/video/android/core/Call;Ljava/lang/String;ILjava/lang/Object;)V + public final fun leave1 (Ljava/lang/String;)V + public static synthetic fun leave1$default (Lio/getstream/video/android/core/Call;Ljava/lang/String;ILjava/lang/Object;)V public final fun listRecordings (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun listRecordings$default (Lio/getstream/video/android/core/Call;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun listTranscription (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 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 6dd28f96fe..7a249d2fe0 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 @@ -392,7 +392,7 @@ public class Call( MediaManagerImpl( clientImpl.context, this, - scope, + restartableProducerScope, eglBase.eglBaseContext, clientImpl.callServiceConfigRegistry.get(type).audioUsage, ) { clientImpl.callServiceConfigRegistry.get(type).audioUsage } @@ -452,10 +452,10 @@ public class Call( private val callCleanupManager = CallCleanupManager( call = this, sessionManager = sessionManager, + callApiDelegate = apiDelegate, client = clientImpl, mediaManagerProvider = { mediaManager }, callReInitializer = callReInitializer, - clientScope = clientImpl.scope, callStatsReporter = callStatsReporter, ) init { @@ -940,11 +940,15 @@ public class Call( } /** Leave the call, but don't end it for other users */ - fun leave(reason: String = "user") { + fun leave1(reason: String = "user") { logger.d { "[leave] #ringing; no args, call_cid:$cid" } internalLeave(null, reason) } + fun leave(reason: String = "user") { + callCleanupManager.leave(reason) + } + private fun internalLeave(disconnectionReason: Throwable?, reason: String) = atomicLeave { monitorSubscriberPCStateJob?.cancel() monitorPublisherPCStateJob?.cancel() 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 373f5424f6..132ca246fb 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 @@ -75,6 +75,8 @@ import stream.video.sfu.models.AudioBitrateProfile import stream.video.sfu.models.VideoDimension import java.nio.ByteBuffer import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.resumeWithException sealed class DeviceStatus { @@ -279,7 +281,7 @@ class ScreenShareManager( private val logger by taggedLogger("Media:ScreenShareManager") - private val _status = MutableStateFlow(DeviceStatus.NotSelected) + internal val _status = MutableStateFlow(DeviceStatus.NotSelected) val status: StateFlow = _status public val isEnabled: StateFlow = _status.mapState { it is DeviceStatus.Enabled } @@ -544,13 +546,14 @@ class MicrophoneManager( // Internal data private val logger by taggedLogger("Media:MicrophoneManager") - private lateinit var audioHandler: AudioHandler - private var setupCompleted: Boolean = false + private var audioHandler: AudioHandler? = null + private var setupCompleted: AtomicBoolean = AtomicBoolean(false) + private var mediaManagerSetupState = AtomicReference(MediaManagerSetupState.NONE) internal var audioManager: AudioManager? = null internal var priorStatus: DeviceStatus? = null // Exposed state - private val _status = MutableStateFlow(DeviceStatus.NotSelected) + internal val _status = MutableStateFlow(DeviceStatus.NotSelected) /** The status of the audio */ val status: StateFlow = _status @@ -716,7 +719,16 @@ class MicrophoneManager( fun cleanup() { ifAudioHandlerInitialized { it.stop() } - setupCompleted = false + setupCompleted.set(false) + audioHandler = null + } + + /** + * Resets the microphone status to NotSelected to allow re-initialization on next join. + */ + internal fun reset() { + _status.value = DeviceStatus.NotSelected + mediaManagerSetupState.set(MediaManagerSetupState.NONE) } fun canHandleDeviceSwitch() = audioUsageProvider.invoke() != AudioAttributes.USAGE_MEDIA @@ -724,12 +736,18 @@ class MicrophoneManager( // Internal logic internal fun setup(preferSpeaker: Boolean = false, onAudioDevicesUpdate: (() -> Unit)? = null) { synchronized(this) { + logger.d { + "[setup] setupCompleted = ${setupCompleted.get()}, mediaManagerSetupState = ${mediaManagerSetupState.get()}" + } + if (mediaManagerSetupState.get() != MediaManagerSetupState.NONE) return + + mediaManagerSetupState.set(MediaManagerSetupState.STARTED) + var capturedOnAudioDevicesUpdate = onAudioDevicesUpdate - if (setupCompleted) { + if (setupCompleted.get()) { capturedOnAudioDevicesUpdate?.invoke() capturedOnAudioDevicesUpdate = null - return } @@ -738,41 +756,50 @@ class MicrophoneManager( audioManager?.allowedCapturePolicy = AudioAttributes.ALLOW_CAPTURE_BY_ALL } - if (canHandleDeviceSwitch() && !::audioHandler.isInitialized) { - audioHandler = AudioSwitchHandler( - context = mediaManager.context, - preferredDeviceList = listOf( - AudioDevice.BluetoothHeadset::class.java, - AudioDevice.WiredHeadset::class.java, - ) + if (preferSpeaker) { - listOf( - AudioDevice.Speakerphone::class.java, - AudioDevice.Earpiece::class.java, - ) - } else { - listOf( - AudioDevice.Earpiece::class.java, - AudioDevice.Speakerphone::class.java, - ) - }, - audioDeviceChangeListener = { devices, selected -> - logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } - - _devices.value = devices.map { it.fromAudio() } - _selectedDevice.value = selected?.fromAudio() - - setupCompleted = true - - capturedOnAudioDevicesUpdate?.invoke() - capturedOnAudioDevicesUpdate = null - }, - ) + if (canHandleDeviceSwitch()) { + if (audioHandler == null) { + // First time initialization + audioHandler = AudioSwitchHandler( + context = mediaManager.context, + preferredDeviceList = listOf( + AudioDevice.BluetoothHeadset::class.java, + AudioDevice.WiredHeadset::class.java, + ) + if (preferSpeaker) { + listOf( + AudioDevice.Speakerphone::class.java, + AudioDevice.Earpiece::class.java, + ) + } else { + listOf( + AudioDevice.Earpiece::class.java, + AudioDevice.Speakerphone::class.java, + ) + }, + audioDeviceChangeListener = { devices, selected -> + logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } + + _devices.value = devices.map { it.fromAudio() } + _selectedDevice.value = selected?.fromAudio() + + setupCompleted.set(true) + mediaManagerSetupState.set(MediaManagerSetupState.FINISHED) + capturedOnAudioDevicesUpdate?.invoke() + capturedOnAudioDevicesUpdate = null + }, + ) - logger.d { "[setup] Calling start on instance $audioHandler" } - audioHandler.start() + logger.d { "[setup] Calling start on instance $audioHandler" } + audioHandler?.start() + } else { + // audioHandler exists but was stopped (cleanup was called), restart it + logger.d { "[setup] Restarting audioHandler after cleanup" } + audioHandler?.start() + mediaManagerSetupState.set(MediaManagerSetupState.FINISHED) + } } else { - logger.d { "[MediaManager#setup] Usage is MEDIA or audioHandle is already initialized" } + logger.d { "[MediaManager#setup] Usage is MEDIA" } capturedOnAudioDevicesUpdate?.invoke() + mediaManagerSetupState.set(MediaManagerSetupState.FINISHED) } } } @@ -783,7 +810,7 @@ class MicrophoneManager( ) private fun ifAudioHandlerInitialized(then: (audioHandler: AudioSwitchHandler) -> Unit) { - if (this::audioHandler.isInitialized) { + if (audioHandler != null) { then(this.audioHandler as AudioSwitchHandler) } else { logger.e { "Audio handler not initialized. Ensure calling setup(), before using the handler." } @@ -829,7 +856,7 @@ class CameraManager( private val logger by taggedLogger("Media:CameraManager") /** The status of the camera. enabled or disabled */ - private val _status = MutableStateFlow(DeviceStatus.NotSelected) + internal val _status = MutableStateFlow(DeviceStatus.NotSelected) public val status: StateFlow = _status /** Represents whether the camera is enabled */ @@ -1174,6 +1201,13 @@ class CameraManager( setupCompleted = false } + /** + * Resets the camera status to NotSelected to allow re-initialization on next join. + */ + internal fun reset() { + _status.value = DeviceStatus.NotSelected + } + private fun createCameraDeviceWrapper( id: String, cameraManager: CameraManager?, @@ -1255,6 +1289,7 @@ class MediaManagerImpl( val audioUsage: Int = defaultAudioUsage, val audioUsageProvider: (() -> Int) = { audioUsage }, ) { + private val logger by taggedLogger("MediaManagerImpl") internal val camera = CameraManager(this, eglBaseContext, DefaultCameraCharacteristicsValidator()) internal val microphone = MicrophoneManager(this, audioUsage, audioUsageProvider) @@ -1376,7 +1411,25 @@ class MediaManagerImpl( // Cleanup camera and microphone infrastructure camera.cleanup() microphone.cleanup() + reset() + } + + /** + * Resets device statuses to NotSelected to allow re-initialization on next join. + * Should be called after cleanup when preparing for rejoin. + */ + internal fun reset() { + logger.d { "[reset]" } + camera.reset() + microphone.reset() + + speaker._status.value = DeviceStatus.NotSelected + screenShare._status.value = DeviceStatus.NotSelected } } fun MediaStreamTrack.trySetEnabled(enabled: Boolean) = safeCall { setEnabled(enabled) } + +internal enum class MediaManagerSetupState { + NONE, STARTED, FINISHED +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt index eba6d357c4..04263d376c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoClient.kt @@ -213,7 +213,7 @@ internal class StreamVideoClient internal constructor( destroyedCalls.put(call.hashCode(), call) } logger.d { "[cleanup] Removing call from cache: ${call.cid}" } - calls.remove(call.cid) +// calls.remove(call.cid) TODO Rahul, uncomment before merge } override fun cleanup() { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallCleanupManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallCleanupManager.kt index 0e73bab0bd..99b729143e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallCleanupManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallCleanupManager.kt @@ -25,7 +25,6 @@ import io.getstream.video.android.core.notifications.internal.telecom.TelecomCal 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.safeCall -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock @@ -37,10 +36,10 @@ import kotlin.sequences.forEach internal class CallCleanupManager( private val call: Call, private val sessionManager: CallSessionManager, + private val callApiDelegate: CallApiDelegate, private val client: StreamVideoClient, private val callReInitializer: CallReInitializer, private val mediaManagerProvider: () -> MediaManagerImpl, // ← Lambda provider - private val clientScope: CoroutineScope, private val callStatsReporter: CallStatsReporter, ) { private val logger by taggedLogger("CallLifecycleManager") @@ -78,14 +77,12 @@ internal class CallCleanupManager( private fun internalLeave(disconnectionReason: Throwable?, reason: String) = call.atomicLeave { sessionManager.cleanupMonitor() + val currentSession = sessionManager.session.get() // Leave session - sessionManager.session.get()?.leaveWithReason( + currentSession?.leaveWithReason( "[reason=$reason, error=${disconnectionReason?.message}]", ) - // Cancel network monitoring - sessionManager.cleanupNetworkMonitoring() - // Update connection state call.state._connection.value = RealtimeConnection.Disconnected @@ -99,9 +96,11 @@ internal class CallCleanupManager( /** * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) */ - call.stopScreenSharing() - mediaManager.camera.disable() - mediaManager.microphone.disable() + callApiDelegate.stopScreenSharing() + with(mediaManager) { + camera.disable() + microphone.disable() + } if (call.id == client.state.activeCall.value?.id) { client.state.removeActiveCall(call) // Will also stop CallService @@ -118,12 +117,15 @@ internal class CallCleanupManager( val newCleanupJob = client.scope.launch { safeCall { - sessionManager.session.get()?.sfuTracer?.trace( - "leave-call", - "[reason=$reason, error=${disconnectionReason?.message}]", - ) - val stats = callStatsReporter.collectStats(sessionManager.session.get()) - sessionManager.session.get()?.sendCallStats(stats) + currentSession?.let { session -> + with(session) { + sfuTracer.trace( + "leave-call", + "[reason=$reason, error=${disconnectionReason?.message}]", + ) + session.sendCallStats(callStatsReporter.collectStats(session)) + } + } } cleanup() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt index 764e3f689e..05f5c5f0b9 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt @@ -373,10 +373,6 @@ internal class CallSessionManager( sfuEventMonitor.stop() } - fun cleanupNetworkMonitoring() { - networkSubscriptionController.stop() - } - fun reset() { this.sessionId.set(UUID.randomUUID().toString()) } From 2db5b00eabb0e1397cdc43af60ff7137f99dac77 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 16:17:03 +0530 Subject: [PATCH 07/13] chore: checkpoint 7, fixed mic and working first join join->leave->join has error in rendering other participant --- .../video/android/core/MediaManager.kt | 14 ++++++++----- .../android/core/MediaManagerSetupState.kt | 21 +++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManagerSetupState.kt 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 132ca246fb..77b46c77a7 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 @@ -739,7 +739,15 @@ class MicrophoneManager( logger.d { "[setup] setupCompleted = ${setupCompleted.get()}, mediaManagerSetupState = ${mediaManagerSetupState.get()}" } - if (mediaManagerSetupState.get() != MediaManagerSetupState.NONE) return + val localMediaManagerSetupState = mediaManagerSetupState.get() + when (localMediaManagerSetupState) { + MediaManagerSetupState.FINISHED -> { + onAudioDevicesUpdate?.invoke() + return@synchronized + } + MediaManagerSetupState.STARTED -> return@synchronized // TODO Rahul, ideally the method call should be queued. Test this before merge + else -> {} + } mediaManagerSetupState.set(MediaManagerSetupState.STARTED) @@ -1429,7 +1437,3 @@ class MediaManagerImpl( } fun MediaStreamTrack.trySetEnabled(enabled: Boolean) = safeCall { setEnabled(enabled) } - -internal enum class MediaManagerSetupState { - NONE, STARTED, FINISHED -} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManagerSetupState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManagerSetupState.kt new file mode 100644 index 0000000000..38df13b8be --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManagerSetupState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core + +internal enum class MediaManagerSetupState { + NONE, STARTED, FINISHED +} From cf24f1577679b79f570b7829fe46d8d945b25535 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 19:01:43 +0530 Subject: [PATCH 08/13] chore: checkpoint 8, no issues in leave->join->leave->join->leave --- .../io/getstream/video/android/core/Call.kt | 4 ++ .../getstream/video/android/core/CallState.kt | 37 +++++++++++++++++++ .../core/call/CallConnectivityMonitor.kt | 4 ++ .../android/core/call/CallSessionManager.kt | 6 +++ 4 files changed, 51 insertions(+) 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 7a249d2fe0..483892161d 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 @@ -327,6 +327,10 @@ public class Call( state._connection.value = RealtimeConnection.Disconnected atomicLeave = AtomicUnitCall() scopeProvider.reset() + + // Clear stale video tracks from participants Claude + state.clearSessionState() + with(restartableProducerScope) { detach() attach(scope) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index 44adbecd8b..ce47c04dc1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -1469,6 +1469,7 @@ public class CallState internal constructor( fun clearParticipants() { internalParticipants.clear() + pendingParticipantsJoined.clear() _participants.value = HashMap(internalParticipants) } @@ -1691,6 +1692,42 @@ public class CallState internal constructor( } } + internal fun clearSessionState() { + clearParticipants() + _activeSpeakers.value = emptyList() + _dominantSpeaker.value = null + _screenSharingSession.value = null + pendingParticipantsJoined.clear() + + _localPins.value = emptyMap() + _serverPins.value = emptyMap() + + _session.value = null + _participantCounts.value = null + + _ringingState.value = RingingState.Idle + _acceptedBy.value = emptySet() + _rejectedBy.value = emptySet() + _rejectActionBundle.value = null + _startedAt.value = null + _reactions.value = emptyList() + _errors.value = emptyList() + _permissionRequests.value = emptyList() + _speakingWhileMuted.value = false + _participantVideoEnabledOverrides.value = emptyMap() + acceptedOnThisDevice = false + + _recording.value = false + _transcribing.value = false + _broadcasting.value = false + _egress.value = null + + _notificationIdFlow.value = null + atomicNotification.set(null) + + cancelTimeout() + } + fun updateRejectedBy(userId: Set) { _rejectedBy.value = userId } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt index 29d1b253b4..ad75416542 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt @@ -55,6 +55,10 @@ internal class CallConnectivityMonitor( } } } + + fun reset() { + leaveTimeoutAfterDisconnect?.cancel() + } } internal data class CallConnectivityMonitorState(var lastDisconnect: Long = 0L, var reconnectDeadlineMils: Int = 10_000) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt index 05f5c5f0b9..1015234f96 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt @@ -374,6 +374,12 @@ internal class CallSessionManager( } fun reset() { + this.session.set(null) this.sessionId.set(UUID.randomUUID().toString()) + reconnectAttempts = 0 + reconnectStartTime = 0L + connectStartTime = 0L + streamSingleFlightProcessorImpl.stop() + callConnectivityMonitor.reset() } } From 91f2d3253c80fc5b95d55d641dd62fcec19c1427 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 19:06:35 +0530 Subject: [PATCH 09/13] chore: checkpoint 9, no issues in leave->join->leave->join->leave --- .../api/stream-video-android-core.api | 4 - .../io/getstream/video/android/core/Call.kt | 143 ------------------ 2 files changed, 147 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 615be6d4ea..7f4987925e 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7608,16 +7608,12 @@ public final class io/getstream/video/android/core/Call { public final fun isVideoEnabled ()Z public final fun join (ZLio/getstream/video/android/core/CreateCallOptions;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun join$default (Lio/getstream/video/android/core/Call;ZLio/getstream/video/android/core/CreateCallOptions;ZZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public final fun join1 (ZLio/getstream/video/android/core/CreateCallOptions;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun join1$default (Lio/getstream/video/android/core/Call;ZLio/getstream/video/android/core/CreateCallOptions;ZZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun joinAndRing (Ljava/util/List;Lio/getstream/video/android/core/CreateCallOptions;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun joinAndRing$default (Lio/getstream/video/android/core/Call;Ljava/util/List;Lio/getstream/video/android/core/CreateCallOptions;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun kickUser (Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun kickUser$default (Lio/getstream/video/android/core/Call;Ljava/lang/String;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun leave (Ljava/lang/String;)V public static synthetic fun leave$default (Lio/getstream/video/android/core/Call;Ljava/lang/String;ILjava/lang/Object;)V - public final fun leave1 (Ljava/lang/String;)V - public static synthetic fun leave1$default (Lio/getstream/video/android/core/Call;Ljava/lang/String;ILjava/lang/Object;)V public final fun listRecordings (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun listRecordings$default (Lio/getstream/video/android/core/Call;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun listTranscription (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; 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 483892161d..c3f624ac7d 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 @@ -88,11 +88,9 @@ 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.utils.AtomicUnitCall import io.getstream.video.android.core.utils.RampValueUpAndDownHelper import io.getstream.video.android.core.utils.StreamSingleFlightProcessorImpl -import io.getstream.video.android.core.utils.safeCall import io.getstream.video.android.core.utils.safeCallWithDefault import io.getstream.video.android.model.User import io.getstream.webrtc.android.ui.VideoTextureViewRenderer @@ -202,8 +200,6 @@ public class Call( */ var audioFilter: InputAudioFilter? = null - // val monitor = CallHealthMonitor(this, scope, onIceRecoveryFailed) - private val soundInputProcessor = SoundInputProcessor(thresholdCrossedCallback = { if (!microphone.isEnabled.value) { state.markSpeakingAsMuted() @@ -517,85 +513,6 @@ public class Call( return apiDelegate.update(custom, settingsOverride, startsAt) } - suspend fun join1( - create: Boolean = false, - createOptions: CreateCallOptions? = null, - ring: Boolean = false, - notify: Boolean = false, - ): Result { - logger.d { - "[join] #ringing; #track; create: $create, ring: $ring, notify: $notify, createOptions: $createOptions" - } - with(callReInitializer) { - waitFromCleanup() - reinitialiseCoroutinesIfNeeded() - } - - isDestroyed.set(false) - - 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() - - // 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) - 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) { - sessionManager.session.set(null) - logger.e { "Join failed with error $result" } - if (isPermanentError(result.value)) { - state._connection.value = RealtimeConnection.Failed(result.value) - return result - } else { - retryCount += 1 - } - } - delay(retryCount - 1 * 1000L) - } - sessionManager.session.set(null) - val errorMessage = "Join failed after 3 retries" - state._connection.value = RealtimeConnection.Failed(errorMessage) - return Failure(value = Error.GenericError(errorMessage)) - } - suspend fun join( create: Boolean = false, createOptions: CreateCallOptions? = null, @@ -944,70 +861,10 @@ public class Call( } /** Leave the call, but don't end it for other users */ - fun leave1(reason: String = "user") { - logger.d { "[leave] #ringing; no args, call_cid:$cid" } - internalLeave(null, reason) - } - fun leave(reason: String = "user") { callCleanupManager.leave(reason) } - private fun internalLeave(disconnectionReason: Throwable?, reason: String) = atomicLeave { - monitorSubscriberPCStateJob?.cancel() - monitorPublisherPCStateJob?.cancel() - monitorPublisherPCStateJob = null - monitorSubscriberPCStateJob = null - sessionManager.session.get()?.leaveWithReason( - "[reason=$reason, error=${disconnectionReason?.message}]", - ) - leaveTimeoutAfterDisconnect?.cancel() - network.unsubscribe(listener) - sfuEvents?.cancel() - state._connection.value = RealtimeConnection.Disconnected - logger.v { "[leave] #ringing; disconnectionReason: $disconnectionReason, call_id = $id" } - if (isDestroyed.get()) { - logger.w { "[leave] #ringing; Call already destroyed, ignoring" } - return@atomicLeave - } - isDestroyed.set(true) - - sfuSocketReconnectionTime = null - - /** - * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) - */ - stopScreenSharing() - camera.disable() - microphone.disable() - - if (id == client.state.activeCall.value?.id) { - client.state.removeActiveCall(this) // Will also stop CallService - } - - if (id == client.state.ringingCall.value?.id) { - client.state.removeRingingCall(this) - } - - TelecomCallController(client.context) - .leaveCall(this) - - (client as StreamVideoClient).onCallCleanUp(this) - - clientImpl.scope.launch { - safeCall { - val session = sessionManager.session.get() - session?.sfuTracer?.trace( - "leave-call", - "[reason=$reason, error=${disconnectionReason?.message}]", - ) - val stats = collectStats() - session?.sendCallStats(stats) - } - cleanup() - } - } - /** ends the call for yourself as well as other users */ suspend fun end(): Result { // end the call for everyone From 9c37842d86107650a926171dc0a28d5eb7e02230 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 19:29:13 +0530 Subject: [PATCH 10/13] chore: checkpoint 9, no issues in leave->join->leave->join->leave --- .../api/stream-video-android-core.api | 1 + .../kotlin/io/getstream/video/android/core/Call.kt | 10 +++++++++- .../video/android/core/call/CallSessionManager.kt | 2 +- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index 7f4987925e..a8a2225420 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -7649,6 +7649,7 @@ public final class io/getstream/video/android/core/Call { public static synthetic fun setIncomingVideoEnabled$default (Lio/getstream/video/android/core/Call;Ljava/lang/Boolean;Ljava/util/List;ILjava/lang/Object;)V public final fun setPreferredIncomingVideoResolution (Lio/getstream/video/android/core/model/PreferredVideoResolution;Ljava/util/List;)V public static synthetic fun setPreferredIncomingVideoResolution$default (Lio/getstream/video/android/core/Call;Lio/getstream/video/android/core/model/PreferredVideoResolution;Ljava/util/List;ILjava/lang/Object;)V + public final fun setSessionId (Ljava/lang/String;)V public final fun setVideoFilter (Lio/getstream/video/android/core/call/video/VideoFilter;)V public final fun setVisibility (Ljava/lang/String;Lstream/video/sfu/models/TrackType;ZLjava/lang/String;)V public final fun setVisibility (Ljava/lang/String;Lstream/video/sfu/models/TrackType;ZLjava/lang/String;II)V 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 c3f624ac7d..e6f466f14e 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 @@ -288,8 +288,15 @@ public class Call( internal val session: RtcSession? get() = sessionManager.session.get() - val sessionId: String + + var sessionId: String get() = sessionManager.sessionId.get() + @Deprecated( + message = "Setter kept for binary compatibility. Do not use.", //TODO Rahul ask in Pr Review, whether to mark it deprecated or not + level = DeprecationLevel.ERROR + ) + set(value) = sessionManager.sessionId.set(value) + internal val reconnectAttempts: Int get() = sessionManager.reconnectAttempts @@ -333,6 +340,7 @@ public class Call( } } + /** * Checks if the audioBitrateProfile has changed since the factory was created, * and recreates the factory if needed. This should only be called before joining. diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt index 1015234f96..8e0fd42a14 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt @@ -359,7 +359,7 @@ internal class CallSessionManager( } private suspend fun schedule(key: String, block: suspend () -> Unit) { - logger.d { "[schedule] #reconnect; no args" } + logger.d { "[schedule] #reconnect; no args, key: $key" } streamSingleFlightProcessorImpl.run(key, block) } From 36fb4507bc21cc057c59f80b9265b1110e19462c Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 21:03:03 +0530 Subject: [PATCH 11/13] chore: checkpoint 10 # Things to fix 1. Self preview is stuck after join->fastReconnect()->leave()->join->fastReconnect() # Things to test 1. Rejoin 2. Migrate --- .../io/getstream/video/android/core/Call.kt | 415 +----------------- .../core/call/CallConnectivityMonitor.kt | 19 + .../android/core/call/CallJoinCoordinator.kt | 3 +- .../android/core/call/CallSessionManager.kt | 17 +- .../utils/StreamSingleFlightProcessorImpl.kt | 4 + 5 files changed, 46 insertions(+), 412 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt index e6f466f14e..a0b4e4c70e 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 @@ -52,12 +52,8 @@ 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.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.audio.StreamAudioDevice import io.getstream.video.android.core.call.CallApiDelegate import io.getstream.video.android.core.call.CallCleanupManager import io.getstream.video.android.core.call.CallEventManager @@ -77,17 +73,14 @@ import io.getstream.video.android.core.call.utils.SoundInputProcessor import io.getstream.video.android.core.call.video.VideoFilter import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope -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.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.utils.AtomicUnitCall import io.getstream.video.android.core.utils.RampValueUpAndDownHelper import io.getstream.video.android.core.utils.StreamSingleFlightProcessorImpl @@ -97,23 +90,16 @@ import io.getstream.webrtc.android.ui.VideoTextureViewRenderer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job 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.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.threeten.bp.OffsetDateTime import org.webrtc.EglBase -import org.webrtc.PeerConnection 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 @@ -168,7 +154,7 @@ public class Call( /** The call state contains all state such as the participant list, reactions etc */ val state = CallState(client, this, user, restartableProducerScope, { sessionManager }) - private val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } +// private val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } /** Camera gives you access to the local camera */ val camera by lazy(LazyThreadSafetyMode.PUBLICATION) { mediaManager.camera } @@ -240,8 +226,6 @@ public class Call( internal var isDestroyed = AtomicBoolean(false) /** Session handles all real time communication for video and audio */ -// internal var session: RtcSession? = null -// var sessionId = UUID.randomUUID().toString() internal val unifiedSessionId = UUID.randomUUID().toString() internal val connectStartTime: Long @@ -279,11 +263,14 @@ public class Call( } val events = MutableSharedFlow(extraBufferCapacity = 150) + internal val streamSingleFlightProcessorImpl = + StreamSingleFlightProcessorImpl(restartableProducerScope) private val callRenderer = CallRenderer() internal val sessionManager = CallSessionManager( call = this, clientImpl = clientImpl, testInstanceProvider = testInstanceProvider, + streamSingleFlightProcessorImpl, ) internal val session: RtcSession? @@ -291,9 +278,10 @@ public class Call( var sessionId: String get() = sessionManager.sessionId.get() + @Deprecated( - message = "Setter kept for binary compatibility. Do not use.", //TODO Rahul ask in Pr Review, whether to mark it deprecated or not - level = DeprecationLevel.ERROR + message = "Setter kept for binary compatibility. Do not use.", // TODO Rahul ask in Pr Review, whether to mark it deprecated or not + level = DeprecationLevel.ERROR, ) set(value) = sessionManager.sessionId.set(value) @@ -315,6 +303,7 @@ public class Call( call = this, client = clientImpl, callReInitializer = callReInitializer, + streamSingleFlightProcessorImpl = streamSingleFlightProcessorImpl, onJoinFail = { sessionManager.session.set(null) }, @@ -340,7 +329,6 @@ public class Call( } } - /** * Checks if the audioBitrateProfile has changed since the factory was created, * and recreates the factory if needed. This should only be called before joining. @@ -351,31 +339,6 @@ public class Call( internal fun ensureFactoryMatchesAudioProfile() = callMediaManager.ensureFactoryMatchesAudioProfile() - /** - * Recreates peerConnectionFactory, audioSource, audioTrack, videoSource and videoTrack - * with the current audioBitrateProfile. This should only be called before the call is joined. - */ - internal fun recreateFactoryAndAudioTracks() { - val wasMicrophoneEnabled = microphone.status.value is DeviceStatus.Enabled - val wasCameraEnabled = camera.status.value is DeviceStatus.Enabled - - // Dispose all tracks and sources first - mediaManager.disposeTracksAndSources() - - // Recreate the factory (which will use the new audioBitrateProfile) - recreatePeerConnectionFactory() - - // Re-enable tracks if they were enabled - if (wasMicrophoneEnabled) { - // audioTrack will be recreated on next access, then we enable it - microphone.enable(fromUser = false) - } - if (wasCameraEnabled) { - // videoTrack will be recreated on next access, then we enable it - camera.enable(fromUser = false) - } - } - /** * Recreates peerConnectionFactory with the current audioBitrateProfile. * This should only be called before the call is joined. @@ -406,56 +369,6 @@ public class Call( ) { 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:$reconnectDeadlineMils" - } - if (lastDisconnect > 0 && elapsedTimeMils < reconnectDeadlineMils) { - logger.d { - "[NetworkStateListener#onConnected] #network; Reconnecting (fast). Time since last disconnect is ${elapsedTimeMils / 1000} seconds. Deadline is ${reconnectDeadlineMils / 1000} seconds" - } - fastReconnect("NetworkStateListener#onConnected") - } else { - logger.d { - "[NetworkStateListener#onConnected] #network; Reconnecting (full). Time since last disconnect is ${elapsedTimeMils / 1000} seconds. Deadline is ${reconnectDeadlineMils / 1000} seconds" - } - rejoin("NetworkStateListener#onConnected") - } - } - - override suspend fun onDisconnected() { - state._connection.value = RealtimeConnection.Reconnecting - 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) - logger.d { - "[NetworkStateListener#onDisconnected] #network; Leaving after being disconnected for ${clientImpl.leaveAfterDisconnectSeconds}" - } - leave() - } - logger.d { "[NetworkStateListener#onDisconnected] #network; at $lastDisconnect" } - } - } - - private var leaveTimeoutAfterDisconnect: Job? = null - private var lastDisconnect = 0L - private var reconnectDeadlineMils: Int = 10_000 - - private var monitorPublisherPCStateJob: Job? = null - private var monitorSubscriberPCStateJob: Job? = null - private var sfuEvents: Job? = null - private val streamSingleFlightProcessorImpl = StreamSingleFlightProcessorImpl(scope) private val callStatsReporter: CallStatsReporter = CallStatsReporter(this) private val callCleanupManager = CallCleanupManager( call = this, @@ -549,126 +462,6 @@ public class Call( } } - 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, - ): Result { - sessionManager.reconnectAttempts = 0 - sfuEvents?.cancel() - - if (sessionManager.session.get() != null) { - return Failure(Error.GenericError("Call $cid 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 - } - location = locationResult.value - - val result = - joinRequest( - sessionManager.getOptions(create), - locationResult.value, - ring = ring, - notify = notify, - ) - - if (result !is Success) { - return result as Failure - } - try { - val localSession = sessionManager.createJoinRtcSessionInner(result.value) - sessionManager.session.set(localSession) - state._connection.value = RealtimeConnection.Joined(localSession) - localSession.connect() - } catch (e: Exception) { - return Failure(Error.GenericError(e.message ?: "RtcSession error occurred.")) - } - client.state.setActiveCall(this) - monitorSession(result.value) - return Success(value = sessionManager.session.get()!!) - } - - private fun Call.monitorSession(result: JoinCallResponse) { - sfuEvents?.cancel() - startCallStatsReporting(result.statsOptions.reportingIntervalMs.toLong()) - // listen to Signal WS - val session = sessionManager.session.get() - sfuEvents = scope.launch { - session?.let { - it.socket.events().collect { event -> - if (event is JoinCallResponseEvent) { - reconnectDeadlineMils = event.fastReconnectDeadlineSeconds * 1000 - logger.d { "[join] #deadline for reconnect is ${reconnectDeadlineMils / 1000} seconds" } - } - } - } - } - monitorPublisherPCStateJob?.cancel() - monitorPublisherPCStateJob = scope.launch { - session?.publisher?.iceState?.collect { - when (it) { - PeerConnection.IceConnectionState.FAILED, PeerConnection.IceConnectionState.DISCONNECTED -> { - session?.publisher?.connection?.restartIce() - } - - else -> { - logger.d { "[monitorPubConnectionState] Ice connection state is $it" } - } - } - } - } - - monitorSubscriberPCStateJob?.cancel() - monitorSubscriberPCStateJob = scope.launch { - session?.subscriber?.iceState?.collect { - when (it) { - PeerConnection.IceConnectionState.FAILED, PeerConnection.IceConnectionState.DISCONNECTED -> { - session?.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) - sessionManager.session.get()?.sendCallStats( - report = collectStats(), - ) - } - } - } - internal suspend fun collectStats(): CallStatsReport { val session = sessionManager.session.get() val publisherStats = session?.getPublisherStats() @@ -697,176 +490,17 @@ public class Call( /** * Fast reconnect to the same SFU with the same participant session. */ - suspend fun fastReconnect(reason: String = "unknown") = schedule("fast") { - val session = sessionManager.session.get() - logger.d { "[fastReconnect] Reconnecting, reconnectAttepmts:$reconnectAttempts" } - session?.prepareReconnect() - this@Call.state._connection.value = RealtimeConnection.Reconnecting - if (session != null) { - sessionManager.reconnectStartTime = System.currentTimeMillis() - -// val session = session!! - val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() - val reconnectDetails = ReconnectDetails( - previous_session_id = prevSessionId, - strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_FAST, - announced_tracks = publishingInfo, - subscriptions = subscriptionsInfo, - reconnect_attempt = reconnectAttempts, - reason = reason, - ) - session.fastReconnect(reconnectDetails) - val oldSessionStats = collectStats() - session.sendCallStats(oldSessionStats) - } else { - logger.d { "[fastReconnect] [RealtimeConnection.Disconnected], call_id:$id" } - this@Call.state._connection.value = RealtimeConnection.Disconnected - } - } + suspend fun fastReconnect(reason: String = "unknown") = sessionManager.fastReconnect(reason) /** * Rejoin a call. Creates a new session and joins as a new participant. */ - suspend fun rejoin(reason: String = "unknown") = schedule("rejoin") { - val session = sessionManager.session.get() - logger.d { "[rejoin] Rejoining" } - sessionManager.reconnectAttempts++ - state._connection.value = RealtimeConnection.Reconnecting - location?.let { - sessionManager.reconnectStartTime = System.currentTimeMillis() - - val joinResponse = joinRequest(location = it) - if (joinResponse is Success) { - // switch to the new SFU - val cred = joinResponse.value.credentials - val oldSession = session!! - val oldSessionStats = collectStats() - val currentOptions = session?.publisher?.currentOptions() - logger.i { "Rejoin SFU ${oldSession?.sfuUrl} to ${cred.server.url}" } - sessionManager.sessionId.set(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 = reconnectAttempts, - reason = reason, - ) - this.state.removeParticipant(prevSessionId) - oldSession.prepareRejoin() - - try { - val session = RtcSession( - clientImpl, - reconnectAttempts, - powerManager, - this, - sessionManager.sessionId.get(), - clientImpl.apiKey, - clientImpl.coordinatorConnectionModule.lifecycle, - cred.server.url, - cred.server.wsEndpoint, - cred.token, - cred.iceServers.map { ice -> - ice.toIceServer() - }, - ) - this.sessionManager.session.set(session) - session.connect(reconnectDetails, currentOptions) - session.sfuTracer.trace("rejoin", reason) - oldSession.sendCallStats(oldSessionStats) - oldSession.leaveWithReason("Rejoin :: $reason") - oldSession.cleanup() - monitorSession(joinResponse.value) - } catch (ex: Exception) { - logger.e(ex) { - "[rejoin] Failed to join response with ex: ${ex.message}" - } - state._connection.value = RealtimeConnection.Failed(ex) - } - } else { - logger.e { - "[rejoin] Failed to get a join response ${joinResponse.errorOrNull()}" - } - state._connection.value = RealtimeConnection.Reconnecting - } - } - } + suspend fun rejoin(reason: String = "unknown") = sessionManager.rejoin(reason) /** * Migrate to another SFU. */ - suspend fun migrate() = schedule("migrate") { - logger.d { "[migrate] Migrating" } - state._connection.value = RealtimeConnection.Migrating - location?.let { - sessionManager.reconnectStartTime = System.currentTimeMillis() - - val joinResponse = joinRequest(location = it) - if (joinResponse is Success) { - // switch to the new SFU - val cred = joinResponse.value.credentials - val session = this.sessionManager.session.get()!! - val currentOptions = session.publisher?.currentOptions() - val oldSfuUrl = session.sfuUrl - logger.i { "Rejoin SFU $oldSfuUrl to ${cred.server.url}" } - val sessionId = UUID.randomUUID().toString() - sessionManager.sessionId.set(sessionId) - val (prevSessionId, subscriptionsInfo, publishingInfo) = session.currentSfuInfo() - val reconnectDetails = ReconnectDetails( - previous_session_id = prevSessionId, - strategy = WebsocketReconnectStrategy.WEBSOCKET_RECONNECT_STRATEGY_MIGRATE, - announced_tracks = publishingInfo, - subscriptions = subscriptionsInfo, - from_sfu_id = oldSfuUrl, - reconnect_attempt = reconnectAttempts, - ) - session.prepareRejoin() - try { - val newSession = RtcSession( - clientImpl, - reconnectAttempts, - powerManager, - this, - sessionId, - clientImpl.apiKey, - clientImpl.coordinatorConnectionModule.lifecycle, - cred.server.url, - cred.server.wsEndpoint, - cred.token, - cred.iceServers.map { ice -> - ice.toIceServer() - }, - ) - val oldSession = this.sessionManager.session.get() - this.sessionManager.session.set(newSession) - session.connect(reconnectDetails, currentOptions) - monitorSession(joinResponse.value) - oldSession?.leaveWithReason("migrating") - oldSession?.cleanup() - } catch (ex: Exception) { - logger.e(ex) { - "[switchSfu] Failed to join during " + - "migration - Error ${ex.message}" - } - state._connection.value = RealtimeConnection.Failed(ex) - } - } else { - logger.e { - "[switchSfu] Failed to get a join response during " + - "migration - falling back to reconnect. Error ${joinResponse.errorOrNull()}" - } - state._connection.value = RealtimeConnection.Reconnecting - } - } - } - - private suspend fun schedule(key: String, block: suspend () -> Unit) { - logger.d { "[schedule] #reconnect; no args" } - - streamSingleFlightProcessorImpl.run(key, block) - } + suspend fun migrate() = sessionManager.migrate() /** Leave the call, but don't end it for other users */ fun leave(reason: String = "user") { @@ -1137,33 +771,6 @@ public class Call( } } - 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) - } - internal fun updateMediaManagerFromSettings(callSettings: CallSettingsResponse) { callMediaManager.updateMediaManagerFromSettings(callSettings) } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt index ad75416542..bd6666e70c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallConnectivityMonitor.kt @@ -39,20 +39,39 @@ internal class CallConnectivityMonitor( override suspend fun onConnected() { leaveTimeoutAfterDisconnect?.cancel() val elapsedTimeMils = System.currentTimeMillis() - state.lastDisconnect + logger.d { + "[NetworkStateListener#onConnected] #network; no args, elapsedTimeMils:$elapsedTimeMils, lastDisconnect:${state.lastDisconnect}, reconnectDeadlineMils:${state.reconnectDeadlineMils}" + } if (state.lastDisconnect > 0 && elapsedTimeMils < state.reconnectDeadlineMils) { + logger.d { + "[NetworkStateListener#onConnected] #network; Reconnecting (fast). Time since last disconnect is ${elapsedTimeMils / 1000} seconds. Deadline is ${state.reconnectDeadlineMils / 1000} seconds" + } onFastReconnect() } else { + logger.d { + "[NetworkStateListener#onConnected] #network; Reconnecting (full). Time since last disconnect is ${elapsedTimeMils / 1000} seconds. Deadline is ${state.reconnectDeadlineMils / 1000} seconds" + } onRejoin() } } override suspend fun onDisconnected() { onDisconnected() + logger.d { + "[NetworkStateListener#onDisconnected] #network; old lastDisconnect:${state.lastDisconnect}, clientImpl.leaveAfterDisconnectSeconds:$leaveAfterDisconnectSeconds" + } state.lastDisconnect = System.currentTimeMillis() + logger.d { + "[NetworkStateListener#onDisconnected] #network; new lastDisconnect:${state.lastDisconnect}" + } leaveTimeoutAfterDisconnect = callScope.launch { delay(leaveAfterDisconnectSeconds * 1000) + logger.d { + "[NetworkStateListener#onDisconnected] #network; Leaving after being disconnected for $leaveAfterDisconnectSeconds" + } onLeaveTimeout() } + logger.d { "[NetworkStateListener#onDisconnected] #network; at ${state.lastDisconnect}" } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt index e8367d3f58..fb89b0a27f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallJoinCoordinator.kt @@ -42,6 +42,7 @@ internal class CallJoinCoordinator( private val call: Call, private val client: StreamVideoClient, private val callReInitializer: CallReInitializer, + private val streamSingleFlightProcessorImpl: StreamSingleFlightProcessorImpl, private val onJoinFail: () -> Unit, private val createJoinSession: suspend ( create: Boolean, @@ -51,8 +52,6 @@ internal class CallJoinCoordinator( ) -> Result, private val onRejoin: suspend (reason: String) -> Unit, ) : CallJoinContract { - private val streamSingleFlightProcessorImpl = - StreamSingleFlightProcessorImpl(call.restartableProducerScope) private val logger by taggedLogger("CallJoinCoordinator") diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt index 8e0fd42a14..4b481fcf69 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/CallSessionManager.kt @@ -44,7 +44,7 @@ internal class CallSessionManager( private val call: Call, private val clientImpl: StreamVideoClient, private val testInstanceProvider: Call.Companion.TestInstanceProvider, - + private val streamSingleFlightProcessorImpl: StreamSingleFlightProcessorImpl, ) { private val logger by taggedLogger("CallSessionManager") private var powerManager: PowerManager? = null @@ -60,7 +60,6 @@ internal class CallSessionManager( private val callConnectivityMonitorState = CallConnectivityMonitorState() internal val network by lazy { clientImpl.coordinatorConnectionModule.networkStateProvider } - private val streamSingleFlightProcessorImpl = StreamSingleFlightProcessorImpl(call.scope) private val callStatsReporter = CallStatsReporter(call) private val callConnectivityMonitor = CallConnectivityMonitor( call.restartableProducerScope, @@ -343,9 +342,10 @@ internal class CallSessionManager( call.state.removeParticipant(prevSessionId) oldSession.prepareRejoin() try { - this.session.set(createRejoinSession(joinResponse)) - this.session.get()?.connect(reconnectDetails, currentOptions) - this.session.get()?.sfuTracer?.trace("rejoin", reason) + val localSession = createRejoinSession(joinResponse) + this.session.set(localSession) + localSession.connect(reconnectDetails, currentOptions) + localSession.sfuTracer.trace("rejoin", reason) oldSession.sendCallStats(oldSessionStats) oldSession.leaveWithReason("Rejoin :: $reason") oldSession.cleanup() @@ -360,7 +360,11 @@ internal class CallSessionManager( private suspend fun schedule(key: String, block: suspend () -> Unit) { logger.d { "[schedule] #reconnect; no args, key: $key" } - streamSingleFlightProcessorImpl.run(key, block) + val result = streamSingleFlightProcessorImpl.run(key, block) + result.onSuccess { logger.d { "[schedule] success #reconnect; no args, key: $key" } } + .onFailure { + logger.d { "[schedule] fail with${result.exceptionOrNull()} #reconnect; no args, key: $key" } + } } fun cleanup() { @@ -380,6 +384,7 @@ internal class CallSessionManager( reconnectStartTime = 0L connectStartTime = 0L streamSingleFlightProcessorImpl.stop() + streamSingleFlightProcessorImpl.reset() callConnectivityMonitor.reset() } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StreamSingleFlightProcessorImpl.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StreamSingleFlightProcessorImpl.kt index 6918e50390..e0c91781fd 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StreamSingleFlightProcessorImpl.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StreamSingleFlightProcessorImpl.kt @@ -107,4 +107,8 @@ internal class StreamSingleFlightProcessorImpl( clear(cancelRunning = true).getOrThrow() } } + + fun reset() { + closed.set(false) + } } From 2990bd7477dcbbcd64f7f855da1a552c5d67dd5a Mon Sep 17 00:00:00 2001 From: rahullohra Date: Tue, 3 Feb 2026 23:57:39 +0530 Subject: [PATCH 12/13] chore: checkpoint 11. Migrate to RestartableStateFlow --- .../api/stream-video-android-core.api | 4 +- .../getstream/video/android/core/CallState.kt | 52 ++++++++------ .../getstream/video/android/core/CallStats.kt | 31 +++++++-- .../video/android/core/ParticipantState.kt | 69 ++++++++++++------- .../coroutines/flows/RestartableStateFlow.kt | 16 +++-- .../video/android/core/utils/StateFlow.kt | 2 +- .../android/mock/StreamPreviewDataUtils.kt | 2 + 7 files changed, 120 insertions(+), 56 deletions(-) diff --git a/stream-video-android-core/api/stream-video-android-core.api b/stream-video-android-core/api/stream-video-android-core.api index a8a2225420..7a70e7545a 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -8131,8 +8131,8 @@ public final class io/getstream/video/android/core/ParticipantState { public final fun component5 ()Lstream/video/sfu/models/ParticipantSource; public final fun component6 ()Ljava/lang/String; public final fun consumeReaction (Lio/getstream/video/android/core/model/Reaction;)V - public final fun copy (Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lio/getstream/video/android/core/CallActions;Ljava/lang/String;Lstream/video/sfu/models/ParticipantSource;Ljava/lang/String;)Lio/getstream/video/android/core/ParticipantState; - public static synthetic fun copy$default (Lio/getstream/video/android/core/ParticipantState;Ljava/lang/String;Lkotlinx/coroutines/CoroutineScope;Lio/getstream/video/android/core/CallActions;Ljava/lang/String;Lstream/video/sfu/models/ParticipantSource;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/video/android/core/ParticipantState; + public final fun copy (Ljava/lang/String;Lio/getstream/video/android/core/coroutines/scopes/RestartableProducerScope;Lio/getstream/video/android/core/CallActions;Ljava/lang/String;Lstream/video/sfu/models/ParticipantSource;Ljava/lang/String;)Lio/getstream/video/android/core/ParticipantState; + public static synthetic fun copy$default (Lio/getstream/video/android/core/ParticipantState;Ljava/lang/String;Lio/getstream/video/android/core/coroutines/scopes/RestartableProducerScope;Lio/getstream/video/android/core/CallActions;Ljava/lang/String;Lstream/video/sfu/models/ParticipantSource;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/video/android/core/ParticipantState; public fun equals (Ljava/lang/Object;)Z public final fun getAudio ()Lkotlinx/coroutines/flow/StateFlow; public final fun getAudioEnabled ()Lkotlinx/coroutines/flow/StateFlow; diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index ce47c04dc1..e85f5fb194 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -388,8 +388,7 @@ public class CallState internal constructor( """, ), ) - val livestream: StateFlow = - livestreamFlow.debounce(1000).stateIn(scope, SharingStarted.WhileSubscribed(10_000L), null) + val livestream: StateFlow private var _sortedParticipantsState: SortedParticipantsState @@ -463,24 +462,13 @@ public class CallState internal constructor( } /** how long the call has been running, rounded to seconds, null if the call didn't start yet */ - public val duration: StateFlow = - _durationInMs.transform { emit(((it ?: 0L) / 1000L).toDuration(DurationUnit.SECONDS)) } - .stateIn(scope, SharingStarted.WhileSubscribed(10000L), null) + public val duration: StateFlow /** how many milliseconds the call has been running, null if the call didn't start yet */ - public val durationInMs: StateFlow = - _durationInMs.stateIn(scope, SharingStarted.WhileSubscribed(10000L), null) + public val durationInMs: StateFlow /** how many milliseconds the call has been running in the simple date format. */ - public val durationInDateFormat: StateFlow = durationInMs.mapState { durationInMs -> - if (durationInMs == null) { - null - } else { - val date = Date(durationInMs) - val dateFormat = SimpleDateFormat("HH:MM:SS", Locale.US) - dateFormat.format(date) - } - } + public val durationInDateFormat: StateFlow /** Check if you have permissions to do things like share your audio, video, screen etc */ public fun hasPermission(permission: String): StateFlow { @@ -673,10 +661,10 @@ public class CallState internal constructor( init { /** * If we assign [_pinnedParticipants] at declaration line, then [restartableProducerScope] - * will be null. As val's are assigned before constructor code has run + * will be null. As val's are assigned before constructor code has run. + * So we cannot use constructor args in class val */ _pinnedParticipants = RestartableStateFlow( - emptyMap(), combine(_localPins, _serverPins) { local, server -> val combined = mutableMapOf() combined.putAll(local) @@ -686,6 +674,7 @@ public class CallState internal constructor( } }, restartableProducerScope, + emptyMap(), ) pinnedParticipants = _pinnedParticipants @@ -700,7 +689,6 @@ public class CallState internal constructor( sortedParticipants = _sortedParticipantsState.asFlow().debounce(100) liveDurationInMs = RestartableStateFlow( - null, flow { while (currentCoroutineContext().isActive) { delay(1000) @@ -716,11 +704,35 @@ public class CallState internal constructor( } }.distinctUntilChanged(), restartableProducerScope, + null, ) liveDuration = liveDurationInMs.mapState { durationInMs -> durationInMs?.takeIf { it >= 1000 }?.let { (it / 1000).toDuration(DurationUnit.SECONDS) } } + + livestream = RestartableStateFlow( + livestreamFlow.debounce(1000), + restartableProducerScope, + null, + SharingStarted.WhileSubscribed(10_000L), + ) + duration = RestartableStateFlow( + _durationInMs.transform { + emit(((it ?: 0L) / 1000L).toDuration(DurationUnit.SECONDS)) + }, + restartableProducerScope, null, SharingStarted.WhileSubscribed(10000L), + ) + durationInMs = RestartableStateFlow(_durationInMs, restartableProducerScope, null, SharingStarted.WhileSubscribed(10000L)) + durationInDateFormat = durationInMs.mapState { durationInMs -> + if (durationInMs == null) { + null + } else { + val date = Date(durationInMs) + val dateFormat = SimpleDateFormat("HH:MM:SS", Locale.US) + dateFormat.format(date) + } + } } fun handleEvent(event: VideoEvent) { @@ -1428,7 +1440,7 @@ public class CallState internal constructor( } else { ParticipantState( sessionId = sessionId, - scope = scope, + restartableProducerScope = restartableProducerScope, callActions = callActions, initialUserId = userId, source = source, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallStats.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallStats.kt index 9e6ab56d45..3f9a3bb8d1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallStats.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallStats.kt @@ -19,12 +19,12 @@ package io.getstream.video.android.core import android.os.Build import io.getstream.log.taggedLogger import io.getstream.video.android.core.call.stats.model.RtcStatsReport +import io.getstream.video.android.core.coroutines.flows.RestartableStateFlow +import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn import org.webrtc.CameraEnumerationAndroid import org.webrtc.RTCStats import stream.video.sfu.models.TrackType @@ -81,18 +81,37 @@ public data class LocalStats( val deviceModel: String, ) -public class CallStats(val call: Call, val callScope: CoroutineScope) { +// TODO Rahul, need to pass RestartableScope +public class CallStats internal constructor( + val call: Call, + private val restartableProducerScope: RestartableProducerScope, +) { + + @Deprecated( + "Kept for binary compatibility.", + level = DeprecationLevel.ERROR, + ) + public constructor( + call: Call, + callScope: CoroutineScope, + ) : this(call, RestartableProducerScope()) private val logger by taggedLogger("CallStats") private val supervisorJob = SupervisorJob() - private val scope = CoroutineScope(callScope.coroutineContext + supervisorJob) + + private val scope = CoroutineScope(restartableProducerScope.coroutineContext + supervisorJob) // TODO: cleanup the scope val publisher = PeerConnectionStats(scope) val subscriber = PeerConnectionStats(scope) val _local = MutableStateFlow(null) - val local: StateFlow = - _local.stateIn(scope, SharingStarted.WhileSubscribed(), null) + val local: StateFlow = RestartableStateFlow(_local, restartableProducerScope, null) + + @Deprecated( + "Kept for binary compatibility.", + level = DeprecationLevel.ERROR, + ) + fun getCallScope(): CoroutineScope = restartableProducerScope fun updateFromRTCStats(stats: RtcStatsReport?, isPublisher: Boolean = true) { if (stats == null) return diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ParticipantState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ParticipantState.kt index 7ae7d26a53..3c06fcfd67 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ParticipantState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ParticipantState.kt @@ -20,6 +20,8 @@ import androidx.compose.runtime.Stable import io.getstream.android.video.generated.models.MuteUsersResponse import io.getstream.log.taggedLogger import io.getstream.result.Result +import io.getstream.video.android.core.coroutines.flows.RestartableStateFlow +import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope import io.getstream.video.android.core.internal.InternalStreamVideoApi import io.getstream.video.android.core.model.AudioTrack import io.getstream.video.android.core.model.MediaTrack @@ -31,10 +33,8 @@ import io.getstream.video.android.core.utils.combineStates import io.getstream.video.android.core.utils.mapState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn import org.threeten.bp.Instant import org.threeten.bp.OffsetDateTime import org.threeten.bp.ZoneOffset @@ -46,24 +46,43 @@ import stream.video.sfu.models.TrackType * Represents the state of a participant in a call. * * * A list of participants is shared when you join a call the SFU send you the participant joined event. - * + * @param sessionId The SFU returns a session id for each participant. This session id is unique + * @param scope The coroutine scope for this participant + * @param callActions The call actions interface for performing operations on this participant + * @param initialUserId The current version of the user, this is the start for participant.user stateflow + * @param source A prefix to identify tracks, internal + * @param trackLookupPrefix */ -@Stable -public data class ParticipantState( - /** The SFU returns a session id for each participant. This session id is unique */ +@Stable // TODO Rahul, need to fix its breaking change before merge +public data class ParticipantState internal constructor( var sessionId: String = "", - /** The coroutine scope for this participant */ - private val scope: CoroutineScope, - /** The call actions interface for performing operations on this participant */ + private val restartableProducerScope: RestartableProducerScope, private val callActions: CallActions, - /** The current version of the user, this is the start for participant.user stateflow */ private val initialUserId: String, val source: ParticipantSource = ParticipantSource.PARTICIPANT_SOURCE_WEBRTC_UNSPECIFIED, - /** A prefix to identify tracks, internal */ @InternalStreamVideoApi var trackLookupPrefix: String = "", ) { + @Deprecated( + "Kept for binary compatibility.", + level = DeprecationLevel.ERROR, + ) + public constructor( + sessionId: String = "", + scope: CoroutineScope, + callActions: CallActions, + initialUserId: String, + source: ParticipantSource = ParticipantSource.PARTICIPANT_SOURCE_WEBRTC_UNSPECIFIED, + trackLookupPrefix: String = "", + ) : this( + sessionId, + RestartableProducerScope(), + callActions, + initialUserId, + source, + trackLookupPrefix, + ) private val logger by taggedLogger("ParticipantState") val isLocal by lazy { @@ -156,18 +175,22 @@ public data class ParticipantState( internal val _reactions = MutableStateFlow>(emptyList()) val reactions: StateFlow> = _reactions - val video: StateFlow = combine( - _videoTrack, - _videoEnabled, - _videoPaused, - ) { track, enabled, paused -> - Video( - sessionId = sessionId, - track = track, - enabled = enabled, - paused = paused, - ) - }.stateIn(scope, SharingStarted.Lazily, null) + val video: StateFlow = RestartableStateFlow( + combine( + _videoTrack, + _videoEnabled, + _videoPaused, + ) { track, enabled, paused -> + Video( + sessionId = sessionId, + track = track, + enabled = enabled, + paused = paused, + ) + }, + restartableProducerScope, + null, + ) val audio: StateFlow = combineStates(_audioTrack, _audioEnabled) { track, enabled -> Audio( diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/flows/RestartableStateFlow.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/flows/RestartableStateFlow.kt index 6cb30f56e9..daf11786e1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/flows/RestartableStateFlow.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/coroutines/flows/RestartableStateFlow.kt @@ -17,11 +17,14 @@ package io.getstream.video.android.core.coroutines.flows import io.getstream.video.android.core.coroutines.scopes.RestartableProducerScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch /** @@ -78,9 +81,10 @@ import kotlinx.coroutines.launch */ @OptIn(ExperimentalForInheritanceCoroutinesApi::class) internal class RestartableStateFlow( - initialValue: T, upstream: Flow, scope: RestartableProducerScope, + initialValue: T, + started: SharingStarted = SharingStarted.WhileSubscribed(), ) : StateFlow { private val state = MutableStateFlow(initialValue) @@ -88,9 +92,13 @@ internal class RestartableStateFlow( init { scope.onAttach { realScope -> realScope.launch { - upstream.collect { value -> - state.value = value - } + upstream + .shareIn( + scope = this, + started = started, + replay = 1, + ) + .collect { state.value = it } } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StateFlow.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StateFlow.kt index 90f64a18ef..c6ae0ac02d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StateFlow.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/utils/StateFlow.kt @@ -24,7 +24,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.stateIn // TODO Rahul Need to migrate to RestartableStateFlow /** * Does not produce the same value in a raw, so respect "distinct until changed emissions" diff --git a/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt b/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt index 61f8956e29..4b4e662a81 100644 --- a/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt +++ b/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt @@ -48,6 +48,7 @@ public object StreamPreviewDataUtils { } /** Mock a [Call] that contains a mock user. */ +@Suppress("DEPRECATION_ERROR") public val previewCall: Call = Call( client = StreamPreviewDataUtils.streamVideo, type = "default", @@ -115,6 +116,7 @@ public val previewUsers: List ) /** Mock a new list of [ParticipantState]. */ +@Suppress("DEPRECATION_ERROR") public val previewParticipantsList: List inline get() { val participants = arrayListOf() From f788990faeeaea053685cf8c31a3efc621b64fc0 Mon Sep 17 00:00:00 2001 From: rahullohra Date: Wed, 4 Feb 2026 00:01:46 +0530 Subject: [PATCH 13/13] chore: checkpoint 12. --- .../src/main/kotlin/io/getstream/video/android/core/CallState.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt index e85f5fb194..3ae01d48bb 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallState.kt @@ -147,7 +147,6 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transform import kotlinx.coroutines.isActive import kotlinx.coroutines.launch