From 27b6e0216e45d25a847aff4dfa8fb468202fa62e Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Fri, 23 Oct 2026 13:02:14 +0530 Subject: [PATCH 01/20] 1. refactor classes --- .../android/robots/UserRobotCallAsserts.kt | 10 ++ .../video/android/tests/CallLifecycleTests.kt | 51 +++++- .../api/stream-video-android-core.api | 1 - .../io/getstream/video/android/core/Call.kt | 58 ++++--- .../getstream/video/android/core/CallState.kt | 86 +++++++--- .../getstream/video/android/core/CallStats.kt | 13 +- .../video/android/core/MediaManager.kt | 15 +- .../video/android/core/StreamVideoClient.kt | 5 + .../core/analytics/call/CallAnalytics.kt | 12 +- .../video/android/core/call/Restartable.kt | 23 +++ .../core/call/components/CallCleanupState.kt | 89 ++++++++++ .../call/components/CallJoinCoordinator.kt | 3 +- .../call/components/CallLifecycleManager.kt | 117 ++++++------- .../core/call/components/CallMediaManager.kt | 34 +++- .../call/components/CallRestartCoordinator.kt | 33 ++++ .../call/components/CallSessionManager.kt | 16 +- .../core/call/scope/RestartableStateFlow.kt | 79 +++++++++ .../android/core/call/scope/ScopeProvider.kt | 11 +- .../core/call/scope/ScopeProviderImpl.kt | 98 ++++++----- .../call/scope/SynchronizedScopeGeneration.kt | 140 ++++++++++++++++ .../core/sorting/SortedParticipantsState.kt | 23 ++- .../core/CallFieldDeclarationOrderTest.kt | 2 +- .../android/core/CallStateRingJoinTest.kt | 3 +- .../android/core/CallStateTelecomHoldTest.kt | 3 +- .../android/core/MicrophoneManagerTest.kt | 26 +++ .../core/RingingStateJoinAndRingTest.kt | 5 +- .../components/CallJoinCoordinatorTest.kt | 76 ++++++++- .../components/CallLifecycleManagerTest.kt | 155 ++++++++++++++++++ .../call/components/CallMediaManagerTest.kt | 33 +++- .../components/CallRestartCoordinatorTest.kt | 41 +++++ .../call/scope/RestartableStateFlowTest.kt | 107 ++++++++++++ .../core/call/scope/ScopeProviderTest.kt | 23 ++- .../video/android/core/e2ee/CallE2EETest.kt | 3 + .../core/rtc/ReconnectEscalationTest.kt | 2 + ...edParticipantsChannelFlowRegressionTest.kt | 2 +- .../sorting/SortedParticipantsStateTest.kt | 2 +- 36 files changed, 1204 insertions(+), 196 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/Restartable.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinator.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManagerTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinatorTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt diff --git a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt index 4b511ce632c..93e0bd5f7af 100644 --- a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt +++ b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt @@ -74,6 +74,16 @@ fun UserRobot.assertThatCallIsEnded(): UserRobot { return this } +fun UserRobot.assertCallUiIsRendered(): UserRobot { + assertTrue( + "Call information", + CallPage.callInfoView.waitDisplayed(timeOutMillis = 30.seconds), + ) + assertTrue("Hang-up control", CallPage.hangUpButton.waitDisplayed()) + assertTrue("Video surface", CallPage.videoView.waitDisplayed()) + return this +} + fun UserRobot.assertUserMicrophone(isEnabled: Boolean, videoCall: Boolean = true): UserRobot { // The participant view icon updates slightly after the control toggle, so both // checks poll instead of asserting the icon at the instant the toggle appears. diff --git a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/CallLifecycleTests.kt b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/CallLifecycleTests.kt index 5f4b2f9ff61..bbf92340b6d 100644 --- a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/CallLifecycleTests.kt +++ b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/tests/CallLifecycleTests.kt @@ -16,11 +16,18 @@ package io.getstream.video.android.tests +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.robots.assertCallUiIsRendered import io.getstream.video.android.robots.assertParticipantsCountOnCall import io.getstream.video.android.robots.assertThatCallIsEnded import io.getstream.video.android.uiautomator.seconds import io.qameta.allure.kotlin.Allure.step import io.qameta.allure.kotlin.AllureId +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Test import java.util.UUID @@ -105,8 +112,9 @@ class CallLifecycleTests : StreamTestCase() { @AllureId("7012") @Test - fun testUserReentersTheCall() { + fun testUserReusesTheSameCallObjectAfterLeaving() { val participants = 1 + lateinit var reusableCall: Call step("GIVEN user starts a call") { userRobot.joinCall() @@ -115,11 +123,44 @@ class CallLifecycleTests : StreamTestCase() { participantRobot.joinCall(callId) userRobot.waitForParticipantsOnCall(participants) } - step("WHEN user re-enters the call as the same user") { - userRobot.endCall().joinCall(callId) + step("AND the call UI is rendered") { + userRobot.assertCallUiIsRendered() + reusableCall = requireNotNull(StreamVideo.instance().state.activeCall.value) { + "Expected the rendered call to be registered as the active call" + } } - step("THEN there is one participant on the call") { - userRobot.assertParticipantsCountOnCall(participants) + step("WHEN user leaves the call") { + userRobot + .endCall() + .assertThatCallIsEnded() + } + step("AND the same Call object joins again") { + val rejoinResult = runBlocking { + withTimeout(60_000) { + reusableCall.join() + } + } + + assertTrue( + "Expected the same Call object to rejoin successfully", + rejoinResult.isSuccess, + ) + assertSame(reusableCall, StreamVideo.instance().state.activeCall.value) + } + step("AND the call screen is opened for the rejoined call") { + userRobot.joinCall(callId) + } + step("THEN the call UI is rendered with the participant") { + userRobot + .waitForParticipantsOnCall(participants) + .assertParticipantsCountOnCall(participants) + .assertCallUiIsRendered() + assertSame(reusableCall, StreamVideo.instance().state.activeCall.value) + } + step("AND user can leave the reused call") { + userRobot + .endCall() + .assertThatCallIsEnded() } } 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 7881bd9d182..42afca739c9 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -9809,7 +9809,6 @@ public abstract interface class io/getstream/video/android/core/CallJoinIntercep } public final class io/getstream/video/android/core/CallState { - public fun (Lio/getstream/video/android/core/StreamVideo;Lio/getstream/video/android/core/Call;Lio/getstream/video/android/model/User;Lkotlinx/coroutines/CoroutineScope;)V public final fun clearParticipants ()V public final fun getAcceptedBy ()Lkotlinx/coroutines/flow/StateFlow; public final fun getActiveSpeakers ()Lkotlinx/coroutines/flow/StateFlow; 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 22db5a6cea9..3acf6809dd5 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 @@ -54,6 +54,7 @@ import io.getstream.log.taggedLogger import io.getstream.result.Result import io.getstream.video.android.core.analytics.call.CallAnalytics import io.getstream.video.android.core.analytics.call.observer.model.JoinAnalyticsModel +import io.getstream.video.android.core.call.Restartable import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.audio.InputAudioFilter import io.getstream.video.android.core.call.components.CallApiClient @@ -65,6 +66,7 @@ import io.getstream.video.android.core.call.components.CallLifecycleManager import io.getstream.video.android.core.call.components.CallMediaManager import io.getstream.video.android.core.call.components.CallReconnector import io.getstream.video.android.core.call.components.CallRenderer +import io.getstream.video.android.core.call.components.CallRestartCoordinator import io.getstream.video.android.core.call.components.CallSessionManager import io.getstream.video.android.core.call.components.CallStatsReporter import io.getstream.video.android.core.call.components.ClientCallRegistry @@ -73,7 +75,6 @@ import io.getstream.video.android.core.call.components.NoiseCancellationPolicy import io.getstream.video.android.core.call.components.RtcSessionFactory import io.getstream.video.android.core.call.components.SessionMonitor import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory -import io.getstream.video.android.core.call.scope.ScopeProvider import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.getstream.video.android.core.call.video.VideoFilter import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings @@ -90,8 +91,6 @@ import io.getstream.video.android.core.notifications.internal.telecom.TelecomCal import io.getstream.video.android.core.recording.RecordingType import io.getstream.video.android.core.ringing.RingJoinSource import io.getstream.video.android.core.ringing.RingStatePoller -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.socket.sfu.state.SfuSocketState import io.getstream.video.android.core.trace.PeerConnectionTraceKey import io.getstream.video.android.core.utils.SerialProcessor @@ -103,8 +102,6 @@ import io.getstream.webrtc.android.ui.VideoTextureViewRenderer import io.getstream.webrtc.audio.JavaAudioDeviceModule.AudioSamples import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -140,13 +137,16 @@ public class Call( val user: User, ) { internal val clientImpl = client as StreamVideoClient - internal val scopeProvider: ScopeProvider = ScopeProviderImpl(clientImpl.scope) + + internal val scopeProvider = ScopeProviderImpl( + coroutineScope = clientImpl.scope, + callCid = "$type:$id", + ) private val logger by taggedLogger("Call:$type:$id") - private val supervisorJob = SupervisorJob() internal var powerManager: PowerManager? = null - internal val scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) + internal val scope: CoroutineScope = scopeProvider /** Delegate that owns the live RTC session state and reconnect bookkeeping. */ private val sessionManager = CallSessionManager() @@ -267,7 +267,7 @@ public class Call( val events: MutableSharedFlow = eventManager.events /** The call state contains all state such as the participant list, reactions etc */ - val state = CallState(client, this, user, scope) + val state = CallState(client, this, user, scopeProvider) internal val callAnalytics = CallAnalytics( @@ -370,31 +370,43 @@ public class Call( type = type, id = id, clientImpl = clientImpl, - scope = scope, + scopeProvider = { scope }, state = state, sessionManager = sessionManager, eglBase = { eglBase }, mediaManagerFactory = mediaManagerFactory, ) + /** Restores persistent components after the previous Call generation has been cleaned up. */ + private val restartCoordinator = CallRestartCoordinator( + scopeProvider = scopeProvider, + restartables = listOf( + sessionManager, + callAnalytics, + Restartable(state::restart), + media, + ), + onRestarted = { + clientImpl.onCallReused(this@Call) + autoOnApplied = false + noiseCancellationSignalPending = false + desiredNoiseCancellationEnabled = false + observeNoiseCancellationSignalTarget() + observeNoiseCancellationPolicy() + }, + ) + /** Delegate that owns leave / end / cleanup teardown and the destroyed flag. */ private val lifecycle: CallLifecycleManager = CallLifecycleManager( clientImpl = clientImpl, sessionManager = sessionManager, - scopeProvider = scopeProvider, callRegistry = callRegistry, - // Lets the REST calls queued before leave finish before the scope is torn down. shutDownJobs = { - UserScope(ClientScope()).launch { - supervisorJob.children.forEach { it.join() } - supervisorJob.cancel() - } - scope.cancel() - // Cancelling the scope already reaches the poller, which runs on the call's job. - // Stopping it explicitly also clears the job reference, so a call torn down and - // re-armed does not see a cancelled job as a running one. stopRingStatePolling() + _ringStatePoller = null + scopeProvider.cleanup() }, + restartCoordinator = restartCoordinator, state = state, callAnalytics = callAnalytics, statsReporter = statsReporter, @@ -569,12 +581,13 @@ public class Call( private var autoOnApplied: Boolean = false init { + state.startScopeJobs() media.startAudioLevelMonitoring() + observeNoiseCancellationSignalTarget() + observeNoiseCancellationPolicy() powerManager = safeCallWithDefault(null) { clientImpl.context.getSystemService(POWER_SERVICE) as? PowerManager } - observeNoiseCancellationSignalTarget() - observeNoiseCancellationPolicy() } /** Basic crud operations */ @@ -612,6 +625,7 @@ public class Call( /** * Joins the call. Concurrent callers share one in-flight attempt. + * The same instance can be joined again after [leave]; pending cleanup finishes first. * * Cancelling this coroutine does not abort that attempt — only [leave] does. * 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 a8357d9964d..c5a27a52290 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 @@ -85,6 +85,8 @@ import io.getstream.log.taggedLogger import io.getstream.result.Result import io.getstream.video.android.core.call.CallType import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.scope.RestartableStateFlow +import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.getstream.video.android.core.closedcaptions.ClosedCaptionManager import io.getstream.video.android.core.closedcaptions.ClosedCaptionsSettings import io.getstream.video.android.core.dispatchers.DispatcherProvider @@ -152,7 +154,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transform import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -230,16 +231,29 @@ internal typealias IsPinned = Boolean * */ @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 scopeProvider: ScopeProviderImpl, ) { private val logger by taggedLogger("CallState") private var participantsVisibilityMonitor: Job? = null + private var participantVisibilityFlow: Flow>? = null + + @InternalStreamVideoApi + val scope: CoroutineScope = scopeProvider + + private fun Flow.restartableStateIn( + started: SharingStarted, + initialValue: T, + ): StateFlow = RestartableStateFlow( + upstream = this, + scopeProvider = scopeProvider, + initialValue = initialValue, + started = started, + ) // Create a CallActions implementation that delegates to the Call object @InternalStreamVideoApi @@ -360,7 +374,7 @@ public class CallState( combined.putAll(server) combined.putAll(local) // local overrides server on key collision combined.toMap() - }.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + }.restartableStateIn(SharingStarted.Eagerly, emptyMap()) internal val _pinnedParticipants: StateFlow> = _pinnedParticipantsDetailed.mapState { detailed -> @@ -396,7 +410,7 @@ public class CallState( val filteredVideo = participants.firstOrNull { it.video.value?.enabled == true }?.video?.value - scope.launch { + launch { if (_backstage.value) { send(null) } else { @@ -405,7 +419,7 @@ public class CallState( } } - scope.launch { + launch { _participants.collect { logger.v { "[livestreamFlow] #track; participants: ${it.size} =>" + "${it.map { "${it.value.userId.value} - ${it.value.video.value?.enabled}" }}" @@ -415,7 +429,7 @@ public class CallState( } // The caller i.e. `livestream` is deprecated as well - call.subscribe { + val subscription = call.subscribe { logger.v { "[livestreamFlow] #track; event.type: ${it.getEventType()}" } if (it is TrackPublishedEvent) { val participant = getOrCreateParticipant( @@ -454,7 +468,7 @@ public class CallState( logger.d { "[livestreamFlow] #track; no args" } emitLivestreamVideo() - awaitClose { } + awaitClose { subscription.dispose() } } @Deprecated( @@ -471,10 +485,12 @@ public class CallState( ), ) val livestream: StateFlow = - livestreamFlow.debounce(1000).stateIn(scope, SharingStarted.WhileSubscribed(10_000L), null) + livestreamFlow.debounce( + 1000, + ).restartableStateIn(SharingStarted.WhileSubscribed(10_000L), null) private val _sortedParticipantsState = SortedParticipantsState( - scope = scope, + scopeProvider = { scopeProvider }, call = call, participants = _participants, pinnedParticipantsDetailed = _pinnedParticipantsDetailed, @@ -576,11 +592,11 @@ public class CallState( /** 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) + .restartableStateIn(SharingStarted.WhileSubscribed(10000L), null) /** 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) + _durationInMs.restartableStateIn(SharingStarted.WhileSubscribed(10000L), null) /** how many milliseconds the call has been running in the simple date format. */ public val durationInDateFormat: StateFlow = durationInMs.mapState { durationInMs -> @@ -643,7 +659,7 @@ public class CallState( emit(duration) } } - }.distinctUntilChanged().stateIn(scope, SharingStarted.WhileSubscribed(10000L), null) + }.distinctUntilChanged().restartableStateIn(SharingStarted.WhileSubscribed(10000L), null) /** * How long the call has been live for, represented as [Duration], or null if the call hasn't been live yet. @@ -922,7 +938,7 @@ public class CallState( } is LocalCallMissedEvent -> { - scope.launch { + scopeProvider.launch { val newRejectedBySet = _rejectedBy.value.toMutableSet() StreamVideo.instanceOrNull()?.let { newRejectedBySet.add(it.user.id) @@ -1339,7 +1355,7 @@ public class CallState( -> closedCaptionManager.handleEvent(event) is CallModerationBlurEvent -> { - scope.launch { + scopeProvider.launch { val callServiceConfig = StreamVideo.instanceOrNull()?.state?.callConfigRegistry?.get(call.type) ?: CallServiceConfig() if (callServiceConfig.moderationConfig.videoModerationConfig.enable) { call.state.moderationManager.applyVideoModeration() @@ -1551,7 +1567,7 @@ public class CallState( if (!shouldJoin) return false call.setJoinSource(source) - autoJoiningCall = scope.launch { + autoJoiningCall = scopeProvider.launch { // errors are handled inside the join function val joinResult = call.join() autoJoiningCall = null @@ -1618,7 +1634,7 @@ public class CallState( fun markSpeakingAsMuted() { _speakingWhileMuted.value = true speakingWhileMutedResetJob?.cancel() - speakingWhileMutedResetJob = scope.launch { + speakingWhileMutedResetJob = scopeProvider.launch { delay(2000) _speakingWhileMuted.value = false } @@ -1758,7 +1774,7 @@ public class CallState( } else { ParticipantState( sessionId = sessionId, - scope = scope, + scope = scopeProvider, callActions = callActions, initialUserId = userId, source = source, @@ -1972,11 +1988,12 @@ public class CallState( * @see CallState.updateParticipantVisibility */ fun updateParticipantVisibilityFlow(flow: Flow>?) { + participantVisibilityFlow = flow // Cancel any previous job. participantsVisibilityMonitor?.cancel() if (flow != null) { - participantsVisibilityMonitor = scope.launch { + participantsVisibilityMonitor = scopeProvider.launch { flow.collectLatest { visibleParticipantIds -> internalParticipants.forEach { if (visibleParticipantIds.contains(it.key)) { @@ -2044,7 +2061,7 @@ public class CallState( private fun observeTelecomHold(repo: JetpackTelecomRepository) { telecomHoldObserverJob?.cancel() - telecomHoldObserverJob = scope.launch(DispatcherProvider.Default) { + telecomHoldObserverJob = scopeProvider.launch(DispatcherProvider.Default) { repo.currentCall .map { (it as? TelecomCall.Registered)?.isOnHold == true } .distinctUntilChanged() @@ -2060,6 +2077,33 @@ public class CallState( } } + internal fun restart() { + internalParticipants.clear() + _participants.value = emptyMap() + _activeSpeakers.value = emptyList() + _dominantSpeaker.value = null + _screenSharingSession.value = null + _speakingWhileMuted.value = false + _audioProcessingEnabled.value = false + _ringingState.value = RingingState.Idle + acceptedOnThisDevice = false + isJoinAndRingInProgress.set(false) + atomicNotification.set(null) + telecomHoldObserverJob = null + autoJoiningCall = null + speakingWhileMutedResetJob = null + participantsVisibilityMonitor = null + _notificationIdFlow.value = null + _serviceRoute.value = ServiceRoute.UNDECIDED + _sortedParticipantsState.restart() + startScopeJobs() + } + + internal fun startScopeJobs() { + participantVisibilityFlow?.let { updateParticipantVisibilityFlow(it) } + jetpackTelecomRepository?.let { observeTelecomHold(it) } + } + internal fun cleanup() { previousRingingStates.clear() activeStateGate.cleanup() 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 210f19db31f..6b20f5e757f 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 @@ -22,11 +22,8 @@ import io.getstream.video.android.core.call.stats.model.RtcStatsReport import io.getstream.webrtc.CameraEnumerationAndroid import io.getstream.webrtc.RTCStats 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 stream.video.sfu.models.TrackType data class MediaStatsInfo( @@ -135,15 +132,11 @@ public data class LocalStats( public class CallStats(val call: Call, val callScope: CoroutineScope) { private val logger by taggedLogger("CallStats") - private val supervisorJob = SupervisorJob() - private val scope = CoroutineScope(callScope.coroutineContext + supervisorJob) - // TODO: cleanup the scope - - val publisher = PeerConnectionStats(scope) - val subscriber = PeerConnectionStats(scope) + val publisher = PeerConnectionStats(callScope) + val subscriber = PeerConnectionStats(callScope) val _local = MutableStateFlow(null) val local: StateFlow = - _local.stateIn(scope, SharingStarted.WhileSubscribed(), null) + _local 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/MediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/MediaManager.kt index dd4dfc39aeb..effe69511a5 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 @@ -555,6 +555,7 @@ class MicrophoneManager( private val logger by taggedLogger("Media:MicrophoneManager") private lateinit var audioHandler: AudioSwitchDecorator + private var audioHandlerStopped = false private var setupCompleted = AtomicBoolean(false) internal var audioManager: AudioManager? = null @@ -1233,7 +1234,8 @@ class MicrophoneManager( } fun cleanup() { - ifAudioHandlerInitialized { it.stop() } + if (!audioHandlerStopped) ifAudioHandlerInitialized { it.stop() } + audioHandlerStopped = true cleanupUsbDeviceDetection() // Dropped with the call that captured it, so a value remembered from a call that ended in // music cannot be put back onto the next one. @@ -1263,7 +1265,7 @@ class MicrophoneManager( setupUsbDeviceDetection() } - if (canHandleDeviceSwitch() && !::audioHandler.isInitialized) { + if (canHandleDeviceSwitch() && (!::audioHandler.isInitialized || audioHandlerStopped)) { // Use default priority (Bluetooth -> Wired -> Earpiece -> Speakerphone) // unless preferSpeaker is true, then prioritize speakerphone over earpiece val preferredDeviceList = listOf( @@ -1315,6 +1317,7 @@ class MicrophoneManager( audioHandler.setCommunicationModeEnabled(false) } + audioHandlerStopped = false logger.d { "[setup] Calling start on instance $audioHandler" } audioHandler.start() } else { @@ -1711,6 +1714,9 @@ class CameraManager( } fun cleanup() { + job?.cancel() + job = null + if (!setupCompleted) return stopCapture() if (::videoCapturer.isInitialized) { videoCapturer.dispose() @@ -2042,6 +2048,11 @@ class MediaManagerImpl( _screenShareVideoSource = null } + /** Reopens track creation only after the previous call scope and media cleanup have finished. */ + internal fun prepareForJoin() { + synchronized(mediaLock) { released = false } + } + fun cleanup() { // Terminal teardown: dispose tracks/sources and mark released so the mic/camera mute paths // ([runOnAudioTrackIfAvailable]/[runOnVideoTrackIfAvailable]) no-op afterwards instead of 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 95f604197ec..179018b14da 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 @@ -281,6 +281,11 @@ internal class StreamVideoClient internal constructor( calls.remove(call.cid) } + internal fun onCallReused(call: Call) { + destroyedCalls.remove(call.hashCode()) + calls[call.cid] = call + } + override fun cleanup() { // remove all cached calls calls.clear() diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt index ea046203f28..462b65f3786 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt @@ -34,6 +34,7 @@ import io.getstream.video.android.core.analytics.call.observer.model.Stage import io.getstream.video.android.core.analytics.call.observer.toVideoAnalyticsIceState import io.getstream.video.android.core.analytics.reporting.ClientEventReporter import io.getstream.video.android.core.analytics.reporting.model.AnalyticsCallAbortReason +import io.getstream.video.android.core.call.Restartable import io.getstream.video.android.core.call.RtcSession import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow @@ -47,7 +48,7 @@ internal class CallAnalytics( val participants: StateFlow>, val eventReporter: ClientEventReporter, val observerScope: CoroutineScope, -) { +) : Restartable { val logger by taggedLogger("CallAnalyticsHooks") val joinAnalyticsStateHolder = JoinAnalyticsStateHolder() @@ -98,6 +99,15 @@ internal class CallAnalytics( sfuAnalyticsStateHolder, ) + override fun restart() { + joinAnalyticsStateHolder.reset() + sfuAnalyticsStateHolder.updateSfuId("") + sfuAnalyticsStateHolder.updateStageId("") + sfuAnalyticsStateHolder.updateStage(Stage.NOT_STARTED) + peerConnectionAnalytics.stateHolder.reset() + resetAfterJoinSuccess() + } + fun resetAfterJoinSuccess() { videoAnalytics.reset() audioAnalytics.reset() diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/Restartable.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/Restartable.kt new file mode 100644 index 00000000000..d7f7ae94f37 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/Restartable.kt @@ -0,0 +1,23 @@ +/* + * 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 + +/** A component that can restore its own state for another Call lifecycle generation. */ +internal fun interface Restartable { + /** Resets values and reinitialises resources owned by this component. */ + fun restart() +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt new file mode 100644 index 00000000000..72dba53e744 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.components + +import kotlinx.coroutines.Job + +/** Stores and synchronizes the cleanup state shared by leave, cleanup and the next join. */ +internal class CallCleanupState { + private data class PendingJobs( + val cleanup: Job?, + val cancelledCall: Job?, + ) + + private val lock = Any() + private var cleanupJob: Job? = null + private var cancelledCallJob: Job? = null + private var cleanedUp = false + + @Volatile + private var destroyed = false + + /** True after leave starts and until cleanup completes and the Call is restarted. */ + val isDestroyed: Boolean get() = destroyed + + /** Runs leave setup once and stores the asynchronous cleanup job before releasing the lock. */ + fun startLeave(createCleanupJob: () -> Job) { + synchronized(lock) { + if (destroyed) return + destroyed = true + cleanupJob = createCleanupJob() + } + } + + /** Runs terminal cleanup once while keeping its job bookkeeping atomic with joining. */ + fun cleanupOnce(cleanup: () -> Unit) { + synchronized(lock) { + if (cleanedUp) return + cleanedUp = true + destroyed = true + cleanup() + } + } + + /** Records the cancelled Call job while [cleanupOnce] still owns the cleanup lock. */ + fun recordCancelledCallJob(job: Job?) { + synchronized(lock) { + cancelledCallJob = job + } + } + + /** Waits for cleanup and cancellation, then atomically restores the active lifecycle state. */ + suspend fun prepareForJoin(restart: () -> Unit) { + while (true) { + val pending = synchronized(lock) { + PendingJobs(cleanupJob, cancelledCallJob) + } + pending.cleanup?.join() + pending.cancelledCall?.join() + + synchronized(lock) { + if (cleanupJob !== pending.cleanup || cancelledCallJob !== pending.cancelledCall) { + return@synchronized + } + if (destroyed) { + restart() + destroyed = false + cleanupJob = null + cancelledCallJob = null + cleanedUp = false + } + return + } + } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt index 47ea5328afb..90b12817265 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -137,6 +137,7 @@ internal class CallJoinCoordinator( callJoinInterceptor: CallJoinInterceptor? = null, ): Result { callAnalytics.joinAnalytics.onJoinFunctionStart() + lifecycle.prepareForJoin() var coalesced = false return joinFlight.run( JOIN_FLIGHT_KEY, @@ -266,7 +267,6 @@ internal class CallJoinCoordinator( var result: Result - lifecycle.resetLeaveGuard() while (retryCount < 3) { result = joinInternal( create, @@ -325,6 +325,7 @@ internal class CallJoinCoordinator( video: Boolean = isVideoEnabled(), callJoinInterceptor: CallJoinInterceptor? = null, ): Result { + lifecycle.prepareForJoin() logger.d { "[joinAndRing] #ringing; #track; members: $members, video: $video" } state.toggleJoinAndRingProgress(true) return join( diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt index ba1a4174e8f..527463174bd 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -24,15 +24,14 @@ import io.getstream.video.android.core.RealtimeConnection import io.getstream.video.android.core.SdkCause import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.analytics.call.CallAnalytics -import io.getstream.video.android.core.call.scope.ScopeProvider -import io.getstream.video.android.core.utils.AtomicUnitCall import io.getstream.video.android.core.utils.safeCall +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Job import kotlinx.coroutines.launch /** - * Owns the call's lifecycle / teardown: leaving, ending, the single-shot leave guard - * ([AtomicUnitCall]), the destroyed flag, and the ordered cleanup of state, session, - * jobs and media. + * Owns leaving, ending, the leave guard and the ordered cleanup of state, session, jobs and media. + * A later join waits for cleanup and cancellation before attaching fresh call resources. * * This component is constructed early in [io.getstream.video.android.core.Call] (the reconnect * and event pipeline depend on it), so collaborators created later are injected as lazy @@ -41,10 +40,10 @@ import kotlinx.coroutines.launch internal class CallLifecycleManager( private val clientImpl: StreamVideoClient, private val sessionManager: CallSessionManager, - private val scopeProvider: ScopeProvider, private val callRegistry: ClientCallRegistry, - /** Cancels the call's supervisor job and scope once in-flight children complete. */ - private val shutDownJobs: () -> Unit, + /** Detaches and cancels the call scope, returning its job so rejoin can await cancellation. */ + private val shutDownJobs: () -> Job?, + private val restartCoordinator: CallRestartCoordinator, private val state: CallState, private val callAnalytics: CallAnalytics, private val statsReporter: CallStatsReporter, @@ -60,12 +59,10 @@ internal class CallLifecycleManager( private val logger by taggedLogger("Call:LifecycleManager:$type:$id") private val cid = "$type:$id" + private val cleanupState = CallCleanupState() - // Atomic controls - private var atomicLeave = AtomicUnitCall() - - /** Call has been left and the object is cleaned up and destroyed. */ - var isDestroyed = false + /** True between leave and the next join, including while cleanup is still running. */ + val isDestroyed: Boolean get() = cleanupState.isDestroyed /** * Time (in millis) when the full reconnection flow started. Will be null again once @@ -73,9 +70,11 @@ internal class CallLifecycleManager( */ private var sfuSocketReconnectionTime: Long? = null - /** Resets the leave guard so a fresh join can run after a previous leave. */ - fun resetLeaveGuard() { - atomicLeave = AtomicUnitCall() + /** Wait outside the call scope, which may already be cancelled by leave. */ + suspend fun prepareForJoin() { + cleanupState.prepareForJoin { + restartCoordinator.restart() + } } fun leave(reason: CallLeaveReason) { @@ -88,38 +87,37 @@ internal class CallLifecycleManager( internalLeave(CallLeaveReason.Custom(reason)) } - private fun internalLeave(reason: CallLeaveReason) = atomicLeave { - stopConnectionMonitors() - callAnalytics.stopObservers() - sessionMonitor().cancelSfuObservers() - state._connection.value = RealtimeConnection.Disconnected - logger.v { "[leave] #ringing; call_id = $id" } - if (isDestroyed) { - logger.w { "[leave] #ringing; Call already destroyed, ignoring" } - return@atomicLeave - } - isDestroyed = true - - sfuSocketReconnectionTime = null - - /** - * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) - */ - media.disableLocalCapture() - - callRegistry.detach() - - clientImpl.scope.launch { - val leaveReason = "[reason=${reason::class.simpleName}, message=${reason.message}]" - callAnalytics.onCallLeave(sessionManager.session, reason) - safeCall { - sessionManager.session.value?.sfuTracer?.trace("leave-call", leaveReason) - val stats = statsReporter.collectStats() - sessionManager.session.value?.sendCallStats(stats) - } - // Must complete before cleanup() cancels the session's supervisor job. - safeCall { sessionManager.session.value?.sendLeaveEvent(leaveReason) } - cleanup() + private fun internalLeave(reason: CallLeaveReason) { + cleanupState.startLeave { + stopConnectionMonitors() + callAnalytics.stopObservers() + sessionMonitor().cancelSfuObservers() + state._connection.value = RealtimeConnection.Disconnected + logger.v { "[leave] #ringing; call_id = $id" } + sfuSocketReconnectionTime = null + + /** + * TODO Rahul, need to check which call has owned the media at the moment(probably use active call) + */ + media.disableLocalCapture() + + callRegistry.detach() + + clientImpl.scope.launch(start = CoroutineStart.LAZY) { + try { + val leaveReason = "[reason=${reason::class.simpleName}, message=${reason.message}]" + callAnalytics.onCallLeave(sessionManager.session, reason) + safeCall { + sessionManager.session.value?.sfuTracer?.trace("leave-call", leaveReason) + val stats = statsReporter.collectStats() + sessionManager.session.value?.sendCallStats(stats) + } + // Must complete before cleanup() cancels the session's supervisor job. + safeCall { sessionManager.session.value?.sendLeaveEvent(leaveReason) } + } finally { + cleanup() + } + }.also { it.start() } } } @@ -138,15 +136,20 @@ internal class CallLifecycleManager( } fun cleanup() { - // monitor.stop() - state.cleanup() - sessionManager.session.value?.cleanup() - shutDownJobs() - statsReporter.stop() - media.cleanup() // TODO Rahul, Verify Later: need to check which call has owned the media at the moment(probably use active call) - sessionManager.setActiveSession(null) - // Cleanup the call's scope provider - scopeProvider.cleanup() + cleanupState.cleanupOnce { + try { + state.cleanup() + sessionManager.session.value?.cleanup() + } finally { + cleanupState.recordCancelledCallJob(shutDownJobs()) + try { + statsReporter.stop() + media.cleanup() + } finally { + sessionManager.setActiveSession(null) + } + } + } } /** Stops the ICE and connectivity monitors. */ diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt index 25781dd514a..db2418b0f62 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -28,12 +28,14 @@ import io.getstream.video.android.core.DeviceStatus import io.getstream.video.android.core.MediaManagerImpl import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.audio.StreamAudioDevice +import io.getstream.video.android.core.call.Restartable import io.getstream.video.android.core.call.connection.StreamPeerConnectionFactory import io.getstream.video.android.core.call.utils.SoundInputProcessor import io.getstream.video.android.core.utils.RampValueUpAndDownHelper import io.getstream.webrtc.EglBase import io.getstream.webrtc.audio.JavaAudioDeviceModule.AudioSamples import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -53,12 +55,15 @@ internal class CallMediaManager( private val type: String, private val id: String, private val clientImpl: StreamVideoClient, - private val scope: CoroutineScope, + private val scopeProvider: () -> CoroutineScope, private val state: CallState, private val sessionManager: CallSessionManager, private val eglBase: () -> EglBase, private val mediaManagerFactory: MediaManagerFactory, -) { +) : Restartable { + private var cameraEnabledBeforeLeave: Boolean? = null + private var microphoneEnabledBeforeLeave: Boolean? = null + private val logger by taggedLogger("Call:MediaManager:$type:$id") private val soundInputProcessor = SoundInputProcessor(thresholdCrossedCallback = { @@ -67,6 +72,7 @@ internal class CallMediaManager( } }) private val audioLevelOutputHelper = RampValueUpAndDownHelper() + private var audioLevelMonitoringJob: Job? = null /** Smoothed local microphone volume level (0..1). */ val localMicrophoneAudioLevel: StateFlow = audioLevelOutputHelper.currentLevel @@ -121,15 +127,18 @@ internal class CallMediaManager( _peerConnectionFactory = value } - val mediaManager by lazy { + private val mediaManagerDelegate = lazy { mediaManagerFactory.create( audioUsageProvider = { clientImpl.callServiceConfigRegistry.get(type).audioUsage }, ) } + val mediaManager: MediaManagerImpl get() = mediaManagerDelegate.value + /** Starts streaming smoothed microphone audio levels into [localMicrophoneAudioLevel]. */ fun startAudioLevelMonitoring() { - scope.launch { + audioLevelMonitoringJob?.cancel() + audioLevelMonitoringJob = scopeProvider().launch { soundInputProcessor.currentAudioLevel.collect { audioLevelOutputHelper.rampToValue(it) } @@ -246,11 +255,16 @@ internal class CallMediaManager( camera.setEnabled(callSettings.video.cameraDefaultOn) } + cameraEnabledBeforeLeave?.let { camera.setEnabled(it) } + cameraEnabledBeforeLeave = null + // Mic if (microphone.status.value == DeviceStatus.NotSelected) { val enabled = callSettings.audio.micDefaultOn microphone.setEnabled(enabled) } + microphoneEnabledBeforeLeave?.let { microphone.setEnabled(it) } + microphoneEnabledBeforeLeave = null } private fun monitorHeadset() { @@ -278,7 +292,7 @@ internal class CallMediaManager( microphone.select(deviceBeforeHeadset) } } - }.launchIn(scope) + }.launchIn(scopeProvider()) } fun startScreenSharing( @@ -403,17 +417,25 @@ internal class CallMediaManager( /** Disables all local capture devices. Used when leaving the call. */ fun disableLocalCapture() { + if (!mediaManagerDelegate.isInitialized()) return + cameraEnabledBeforeLeave = mediaManager.camera.status.value is DeviceStatus.Enabled + microphoneEnabledBeforeLeave = mediaManager.microphone.status.value is DeviceStatus.Enabled stopScreenSharing() mediaManager.camera.disable() mediaManager.microphone.disable() } + override fun restart() { + startAudioLevelMonitoring() + if (mediaManagerDelegate.isInitialized()) mediaManager.prepareForJoin() + } + fun cleanup() { // The wanted state must not outlive the call: a reused Call would otherwise re-apply it // to the factory built for the next session. resetDesiredAudioProcessing() resetDesiredHardwareNoiseSuppressor() resetDesiredHardwareAcousticEchoCanceler() - mediaManager.cleanup() + if (mediaManagerDelegate.isInitialized()) mediaManager.cleanup() } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinator.kt new file mode 100644 index 00000000000..ebbd08073fa --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinator.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.components + +import io.getstream.video.android.core.call.Restartable + +/** Coordinates restoration of a Call after its previous lifecycle generation has ended. */ +internal class CallRestartCoordinator( + private val scopeProvider: Restartable, + private val restartables: List, + private val onRestarted: () -> Unit = {}, +) { + /** Restarts generation-owned resources first, then each persistent component in order. */ + fun restart() { + scopeProvider.restart() + restartables.forEach(Restartable::restart) + onRestarted() + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt index 472ee2a227b..c3516f59333 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt @@ -16,6 +16,7 @@ package io.getstream.video.android.core.call.components +import io.getstream.video.android.core.call.Restartable import io.getstream.video.android.core.call.RtcSession import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,7 +32,7 @@ import java.util.concurrent.ConcurrentHashMap * Keeping this state in a single component gives the join, reconnect, connectivity and * lifecycle collaborators a single source of truth to depend on. */ -internal class CallSessionManager() { +internal class CallSessionManager() : Restartable { private val _session: MutableStateFlow = MutableStateFlow(null) /** @@ -48,7 +49,18 @@ internal class CallSessionManager() { var sessionId = UUID.randomUUID().toString() - val unifiedSessionId = UUID.randomUUID().toString() + var unifiedSessionId = UUID.randomUUID().toString() + private set + + override fun restart() { + sessionId = UUID.randomUUID().toString() + unifiedSessionId = UUID.randomUUID().toString() + nonFastReconnectAttempts = 0 + connectStartTime = 0L + reconnectStartTime = 0L + location = null + clearFailedSfuIds() + } /** Cached SFU location used when (re)joining a call. */ var location: String? = null diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt new file mode 100644 index 00000000000..7d992848a02 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.scope + +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.InternalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingCommand +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +/** + * Keeps state storage and subscriber identity across call scopes. Sharing commands are driven + * by the exposed state's subscribers, so WhileSubscribed does not collect without subscribers. + */ +@OptIn(InternalCoroutinesApi::class, ExperimentalForInheritanceCoroutinesApi::class) +internal class RestartableStateFlow( + private val upstream: Flow, + private val scopeProvider: ScopeProviderImpl, + private val initialValue: T, + private val started: SharingStarted, +) : StateFlow { + private val state = MutableStateFlow(initialValue) + private var producerJob: Job? = null + + init { + scopeProvider.onRestart(::restartProducer) + restartProducer() + } + + private fun restartProducer() { + producerJob?.cancel() + state.value = initialValue + // TODO: Revisit whether this manual SharingStarted implementation can be simplified. + // It currently uses the exposed StateFlow's subscriber count to preserve stateIn's + // WhileSubscribed and replay-reset behavior across scope restarts. + producerJob = scopeProvider.launch { + started.command(state.subscriptionCount).distinctUntilChanged().collectLatest { command -> + when (command) { + SharingCommand.START -> upstream.collect { state.value = it } + SharingCommand.STOP -> Unit + SharingCommand.STOP_AND_RESET_REPLAY_CACHE -> state.value = initialValue + } + } + } + } + + override val value: T get() = state.value + override val replayCache: List get() = state.replayCache + override suspend fun collect(collector: FlowCollector): Nothing { + state.collect(collector) + } +} + +internal fun Flow.restartableStateIn( + scopeProvider: ScopeProviderImpl, + started: SharingStarted, + initialValue: T, +): RestartableStateFlow = RestartableStateFlow(this, scopeProvider, initialValue, started) 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 28d7b319442..e0e5a576968 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 @@ -16,14 +16,13 @@ package io.getstream.video.android.core.call.scope +import io.getstream.video.android.core.call.Restartable import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job -/** - * Provider for coroutine scopes used in RTC sessions. - * This allows for centralized management and reuse of scopes across SDK instances. - */ -internal interface ScopeProvider { +/** Owns a Call's coroutine generations and creates the scopes used by its RTC session. */ +internal interface ScopeProvider : CoroutineScope, Restartable { /** * Provides the main coroutine scope for RTC session operations. * This scope is based on the client's main scope with a supervisor job. @@ -39,5 +38,5 @@ internal interface ScopeProvider { /** * Cleans up resources when the provider is no longer needed. */ - fun cleanup() + fun cleanup(): Job? } 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 50a80c90ee8..f8ee4252a7c 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 @@ -20,33 +20,43 @@ import io.getstream.log.taggedLogger import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher -import java.util.concurrent.ExecutorService +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors +import kotlin.coroutines.CoroutineContext import kotlin.jvm.Throws /** - * Implementation of [ScopeProvider] that manages coroutine scopes for RTC sessions. + * Coroutine-scope owner for a reusable Call and its RTC session. * * This implementation ensures that: + * - Each generation has its own call [Job] * - Each Call gets its own dedicated single thread executor * - RtcSessions use the thread from their parent Call - * - Executors are created on demand and cleaned up with the Call - * - Scopes are properly managed and cleaned up + * - Cleanup cancels the current generation; a later join restarts this provider */ internal class ScopeProviderImpl( private val coroutineScope: CoroutineScope, + private val callCid: String, ) : ScopeProvider { - private val logger by taggedLogger("ScopeProviderImpl") - private var executor: ExecutorService? = null - private var isCleanedUp = false + private val logger by taggedLogger("ScopeProvider:$callCid") + private val generation = SynchronizedScopeGeneration( + createCallJob = { SupervisorJob(coroutineScope.coroutineContext[Job]) }, + ) + private val restartListeners = CopyOnWriteArrayList<() -> Unit>() + + internal val debugInstanceId: Int get() = generation.current().debugInstanceId + + override val coroutineContext: CoroutineContext + get() = coroutineScope.coroutineContext + generation.current().callJob @Throws(IllegalStateException::class) override fun getCoroutineScope(supervisorJob: CompletableJob): CoroutineScope { - // Future: We should not throw exception. Instead return Result.Fail - check(!isCleanedUp) { "ScopeProvider has been cleaned up" } - logger.d { "Creating coroutine scope for RTC session main" } + val currentGeneration = generation.requireActive() + logger.d { "${currentGeneration.logPrefix()} Creating coroutine scope for RTC session main" } return CoroutineScope( coroutineScope.coroutineContext + supervisorJob + @@ -56,45 +66,49 @@ internal class ScopeProviderImpl( @Throws(IllegalStateException::class) override fun getRtcSessionScope(supervisorJob: CompletableJob, callId: String): CoroutineScope { - // Future: We should not throw exception. Instead return Result.Fail - check(!isCleanedUp) { "ScopeProvider has been cleaned up" } - logger.d { "Creating RTC session scope for callId: $callId" } - - // Get or create executor for this call - if (executor == null) { - logger.d { "Creating new single thread executor for callId: $callId" } - executor = Executors.newSingleThreadExecutor { runnable -> + val executorAccess = generation.getOrCreateExecutor { + Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "rtc-call-$callId") } } + if (executorAccess.created) { + logger.d { + "${executorAccess.generation.logPrefix()} Creating new single thread executor for callId: $callId" + } + } + logger.d { + "${executorAccess.generation.logPrefix()} Creating RTC session scope with dedicated executor for callId: $callId" + } + return CoroutineScope( + coroutineScope.coroutineContext + + supervisorJob + + CoroutineName("rtc-session-coroutine") + + executorAccess.executor.asCoroutineDispatcher(), + ) + } - val currentExecutor = executor - return if (currentExecutor != null) { - logger.d { "Creating RTC session scope with dedicated executor for callId: $callId" } - CoroutineScope( - coroutineScope.coroutineContext + - supervisorJob + - CoroutineName("rtc-session-coroutine") + - currentExecutor.asCoroutineDispatcher(), - ) - } else { - // Fallback to regular scope without executor if executor is null - logger.w { "Executor is null, falling back to regular scope for callId: $callId" } - CoroutineScope( - coroutineScope.coroutineContext + - supervisorJob + - CoroutineName("rtc-session-coroutine"), - ) + override fun cleanup(): Job? { + val cleanupAccess = generation.cleanup() + if (cleanupAccess.cleanedUpNow) { + cleanupAccess.generation.callJob.cancel() + logger.d { + "${cleanupAccess.generation.logPrefix()} Cleaning up ScopeProvider and shutting down executor" + } + cleanupAccess.executor?.shutdown() } + return cleanupAccess.generation.callJob } - override fun cleanup() { - if (isCleanedUp) return - isCleanedUp = true - logger.d { "Cleaning up ScopeProvider and shutting down executor" } + override fun restart() { + val nextGeneration = generation.restart() + logger.d { "${nextGeneration.logPrefix()} Restarted ScopeProvider" } + restartListeners.forEach { it() } + } - // Shutdown the executor - executor?.shutdown() - executor = null + internal fun onRestart(listener: () -> Unit) { + restartListeners += listener } + + private fun SynchronizedScopeGeneration.Generation.logPrefix(): String = + "[debug-instance-$debugInstanceId]" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt new file mode 100644 index 00000000000..bbb5a640c59 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt @@ -0,0 +1,140 @@ +/* + * 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.scope + +import kotlinx.coroutines.CompletableJob +import java.util.concurrent.ExecutorService +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference + +/** + * Provides synchronized access to the resources that change between Call scope generations. + * + * Synchronization is isolated here so [ScopeProviderImpl] can focus on creating coroutine scopes + * and applying cleanup and restart lifecycle operations. + */ +internal class SynchronizedScopeGeneration( + private val createCallJob: () -> CompletableJob, +) { + + internal data class Generation( + val debugInstanceId: Int, + val callJob: CompletableJob, + ) + + internal data class ExecutorAccess( + val generation: Generation, + val executor: ExecutorService, + val created: Boolean, + ) + + internal data class CleanupAccess( + val generation: Generation, + val executor: ExecutorService?, + val cleanedUpNow: Boolean, + ) + + private class SynchronizedGeneration( + val generation: Generation, + ) { + val lock = Any() + var executor: ExecutorService? = null + var isCleanedUp: Boolean = false + } + + private val restartLock = Any() + private val debugInstanceCounter = AtomicInteger(0) + private val current = AtomicReference(createGeneration()) + + internal fun current(): Generation = current.get().generation + + internal fun requireActive(): Generation { + val currentGeneration = current.get() + return synchronized(currentGeneration.lock) { + checkActive(currentGeneration) + currentGeneration.generation + } + } + + internal fun getOrCreateExecutor( + factory: () -> ExecutorService, + ): ExecutorAccess { + val currentGeneration = current.get() + return synchronized(currentGeneration.lock) { + checkActive(currentGeneration) + val existingExecutor = currentGeneration.executor + if (existingExecutor != null) { + ExecutorAccess( + generation = currentGeneration.generation, + executor = existingExecutor, + created = false, + ) + } else { + val newExecutor = factory() + currentGeneration.executor = newExecutor + ExecutorAccess( + generation = currentGeneration.generation, + executor = newExecutor, + created = true, + ) + } + } + } + + internal fun cleanup(): CleanupAccess { + val currentGeneration = current.get() + return synchronized(currentGeneration.lock) { + if (currentGeneration.isCleanedUp) { + return@synchronized CleanupAccess( + generation = currentGeneration.generation, + executor = null, + cleanedUpNow = false, + ) + } + + currentGeneration.isCleanedUp = true + val executor = currentGeneration.executor + currentGeneration.executor = null + CleanupAccess( + generation = currentGeneration.generation, + executor = executor, + cleanedUpNow = true, + ) + } + } + + internal fun restart(): Generation = synchronized(restartLock) { + val currentGeneration = current.get() + synchronized(currentGeneration.lock) { + check(currentGeneration.isCleanedUp) { + "ScopeProvider cannot restart before its current generation is cleaned up" + } + createGeneration().also(current::set).generation + } + } + + private fun createGeneration(): SynchronizedGeneration = SynchronizedGeneration( + generation = Generation( + debugInstanceId = debugInstanceCounter.incrementAndGet(), + callJob = createCallJob(), + ), + ) + + private fun checkActive(generation: SynchronizedGeneration) { + check(!generation.isCleanedUp) { "ScopeProvider has been cleaned up" } + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsState.kt index 15fa0b9e672..103616afbda 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsState.kt @@ -19,8 +19,10 @@ package io.getstream.video.android.core.sorting import io.getstream.log.taggedLogger import io.getstream.video.android.core.Call import io.getstream.video.android.core.ParticipantState +import io.getstream.video.android.core.call.Restartable import io.getstream.video.android.core.pinning.PinEntry import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine @@ -35,19 +37,19 @@ import kotlinx.coroutines.sync.withLock * any collector subscribed (the builder block launched coroutines on the outer scope and * returned immediately, so subsequent trySend calls hit a closed channel). The new * implementation backs the public list with a [MutableStateFlow] and writes to it from - * collectors launched on the provided [scope]. + * collectors launched on the current scope returned by [scopeProvider]. * * Sorting is driven by a [SortPreset] (default [SortPreset.Default]) resolved against the * latest pin snapshot before each pass. Consumers can override with a custom * [Comparator] via [updateComparator] for ad-hoc orderings. */ internal class SortedParticipantsState( - scope: CoroutineScope, + private val scopeProvider: () -> CoroutineScope, private val call: Call, private val participants: StateFlow>, private val pinnedParticipantsDetailed: StateFlow>, initialPreset: SortPreset = SortPreset.Default, -) { +) : Restartable { private val logger by taggedLogger("ParticipantSorting") private val presetFlow = MutableStateFlow(initialPreset) @@ -62,9 +64,20 @@ internal class SortedParticipantsState( private val _sortedParticipants = MutableStateFlow>(emptyList()) val sortedParticipants: StateFlow> = _sortedParticipants + private var inputsJob: Job? = null + private var eventsJob: Job? = null init { - scope.launch { + restart() + } + + override fun restart() { + inputsJob?.cancel() + eventsJob?.cancel() + lastSortOrder = emptyList() + _sortedParticipants.value = emptyList() + val scope = scopeProvider() + inputsJob = scope.launch { combine( participants, pinnedParticipantsDetailed, @@ -74,7 +87,7 @@ internal class SortedParticipantsState( SortInputs(p, pins, preset, custom) }.collect { resort(it) } } - scope.launch { + eventsJob = scope.launch { call.events.collect { resort(currentSortInputs()) } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt index a451e605966..15732dd3ff6 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallFieldDeclarationOrderTest.kt @@ -23,7 +23,7 @@ import java.io.File /** * Structural regression guard for a leaking-this race in [Call]'s primary constructor. * - * `Call` declares `val state = CallState(client, this, user, scope)` which passes + * `Call` declares `val state = CallState(client, this, user, scopeProvider)` which passes * `this` to [CallState]. CallState constructs [io.getstream.video.android.core.sorting.SortedParticipantsState], * whose `init` launches `scope.launch { call.events.collect { ... } }`. The * `events` flow is owned by `eventManager` (CallEventManager) and `Call.events` diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateRingJoinTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateRingJoinTest.kt index cb78783101b..37b6ba0d790 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateRingJoinTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateRingJoinTest.kt @@ -23,6 +23,7 @@ import io.getstream.result.Result import io.getstream.video.android.core.base.TestBase import io.getstream.video.android.core.base.toResponse import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.getstream.video.android.core.ringing.RingJoinSource import io.getstream.video.android.core.utils.toResponse import io.getstream.video.android.model.User @@ -111,7 +112,7 @@ internal class CallStateRingJoinTest : TestBase() { /** An outgoing ring we started, registered as ringing, with nobody having answered yet. */ private fun outgoingRing(): CallState { ringingCall.value = call - val callState = CallState(client, call, user, scope) + val callState = CallState(client, call, user, ScopeProviderImpl(scope, "default:test-call")) callState.updateFromResponse(call.toResponse(user.toResponse())) assertThat(callState.ringingState.value) .isEqualTo(RingingState.Outgoing(acceptedByCallee = false)) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt index 9c9212e3f5f..1d16957b561 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt @@ -19,6 +19,7 @@ package io.getstream.video.android.core import io.getstream.android.video.api.generated.models.VideoEvent import io.getstream.video.android.core.base.TestBase import io.getstream.video.android.core.base.toResponse +import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.getstream.video.android.core.notifications.internal.telecom.jetpack.JetpackTelecomRepository import io.getstream.video.android.core.notifications.internal.telecom.jetpack.TelecomCall import io.getstream.video.android.core.utils.toResponse @@ -72,7 +73,7 @@ internal class CallStateTelecomHoldTest : TestBase() { @Test fun `putting an active call on hold leaves the call with CALL_ON_HOLD`() { - val callState = CallState(client, call, user, scope) + val callState = CallState(client, call, user, ScopeProviderImpl(scope, "default:test-call")) callState.updateFromResponse(call.toResponse(user.toResponse())) activeCall.value = call callState.updateRingingState() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt index e1affb66668..74017e180da 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt @@ -30,6 +30,7 @@ import io.getstream.android.video.api.generated.models.AudioSettingsResponse import io.getstream.android.video.api.generated.models.CallSettingsResponse import io.getstream.android.video.api.generated.models.OwnCapability import io.getstream.video.android.core.audio.AudioHandler +import io.getstream.video.android.core.audio.AudioSwitchDecorator import io.getstream.video.android.core.audio.StreamAudioDevice import io.getstream.video.android.core.audio.UsbAudioInputDevice import io.getstream.video.android.core.call.RtcSession @@ -41,8 +42,10 @@ import io.getstream.webrtc.AudioTrack import io.mockk.clearMocks import io.mockk.every import io.mockk.mockk +import io.mockk.mockkConstructor import io.mockk.slot import io.mockk.spyk +import io.mockk.unmockkConstructor import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.channels.Channel @@ -139,6 +142,29 @@ class MicrophoneManagerTest { } } + @Test + fun `setup starts fresh audio routing after cleanup`() { + mockkConstructor(AudioSwitchDecorator::class) + try { + every { anyConstructed().start() } returns Unit + every { anyConstructed().stop() } returns Unit + val mediaManager = mockk(relaxed = true) + val context = mockk(relaxed = true) + every { mediaManager.context } returns context + val audioManager = mockk(relaxed = true) + every { context.getSystemService(Context.AUDIO_SERVICE) } returns audioManager + every { context.getSystemService(AudioManager::class.java) } returns audioManager + val microphone = MicrophoneManager(mediaManager, audioUsage, audioUsageProvider) + microphone.setup() + microphone.cleanup() + microphone.setup() + verify(exactly = 2) { anyConstructed().start() } + verify(exactly = 1) { anyConstructed().stop() } + } finally { + unmockkConstructor(AudioSwitchDecorator::class) + } + } + @Test fun `resume calls setup again after cleanup`() { val mediaManager = mockk(relaxed = true) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/RingingStateJoinAndRingTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/RingingStateJoinAndRingTest.kt index 04b93ac9661..dd4399ee6ec 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/RingingStateJoinAndRingTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/RingingStateJoinAndRingTest.kt @@ -19,6 +19,7 @@ package io.getstream.video.android.core import io.getstream.android.video.api.generated.models.VideoEvent import io.getstream.video.android.core.base.TestBase import io.getstream.video.android.core.base.toResponse +import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.getstream.video.android.core.events.JoinCallResponseEvent import io.getstream.video.android.core.events.ParticipantCount import io.getstream.video.android.core.utils.toResponse @@ -91,7 +92,7 @@ internal class RingingStateJoinAndRingTest : TestBase() { } private fun callStateInJoinAndRingWindow(): CallState { - val callState = CallState(client, call, user, scope) + val callState = CallState(client, call, user, ScopeProviderImpl(scope, "default:test-call")) // The call was created by us; nobody accepted or rejected yet. callState.updateFromResponse(call.toResponse(user.toResponse())) // joinAndRing() toggled the flag and the join completed (active call registered), @@ -114,7 +115,7 @@ internal class RingingStateJoinAndRingTest : TestBase() { @Test fun `a recompute before the SFU join sets Outgoing still yields Idle`() { - val callState = CallState(client, call, user, scope) + val callState = CallState(client, call, user, ScopeProviderImpl(scope, "default:test-call")) callState.updateFromResponse(call.toResponse(user.toResponse())) callState.toggleJoinAndRingProgress(true) activeCall.value = call diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt index c216deab323..e9dcbd42b7f 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -36,6 +36,7 @@ import io.getstream.video.android.core.base.DispatcherRule import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectFailureCause import io.getstream.video.android.core.call.SfuConnectionResult +import io.getstream.video.android.core.call.scope.ScopeProviderImpl import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -44,6 +45,9 @@ import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel @@ -51,6 +55,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -121,15 +126,20 @@ class CallJoinCoordinatorTest { unmockkAll() } - private fun coordinator() = CallJoinCoordinator( + private fun coordinator( + scope: CoroutineScope = testScope, + lifecycle: CallLifecycleManager = this.lifecycle, + sessionFactory: RtcSessionFactory = + RtcSessionFactory { _, _, _, _, _, _, _ -> mockSession }, + ) = CallJoinCoordinator( clientImpl = clientImpl, state = state, callAnalytics = callAnalytics, type = "default", id = "test-call", - scope = testScope, + scope = scope, sessionManager = sessionManager, - sessionFactory = RtcSessionFactory { _, _, _, _, _, _, _ -> mockSession }, + sessionFactory = sessionFactory, media = media, lifecycle = lifecycle, apiClient = apiClient, @@ -147,6 +157,64 @@ class CallJoinCoordinatorTest { } returns result } + @Test + fun `join succeeds repeatedly after leave with a restarted scope provider`() = runTest( + testDispatcher, + ) { + every { clientImpl.scope } returns backgroundScope + val provider = ScopeProviderImpl(backgroundScope, "default:test-call") + val actualLifecycle = CallLifecycleManager( + clientImpl = clientImpl, + sessionManager = sessionManager, + callRegistry = callRegistry, + shutDownJobs = { provider.cleanup() }, + restartCoordinator = CallRestartCoordinator( + scopeProvider = provider, + restartables = emptyList(), + ), + state = state, + callAnalytics = callAnalytics, + statsReporter = mockk(relaxed = true), + media = media, + iceMonitor = mockk(relaxed = true), + sessionMonitor = { sessionMonitor }, + connectivityMonitor = { mockk(relaxed = true) }, + type = "default", + id = "test-call", + ) + val providerJobs = mutableListOf() + val coordinator = coordinator( + scope = backgroundScope, + lifecycle = actualLifecycle, + sessionFactory = RtcSessionFactory { _, _, _, _, _, _, _ -> + // RtcSession needs this provider on every join, including after cleanup. + providerJobs += provider.coroutineContext[Job]!! + val job = SupervisorJob() + provider.getRtcSessionScope(job, "test-call") + job.cancel() + mockSession + }, + ) + stubJoinCall(Success(mockJoinResponse)) + coEvery { mockSession.connectInternal() } returns SfuConnectionResult.Success + repeat(3) { + assertThat(coordinator.join()).isInstanceOf(Success::class.java) + val gate = CompletableDeferred() + coEvery { mockSession.sendLeaveEvent(any()) } coAnswers { gate.await() } + actualLifecycle.leave() + val nextJoin = async { coordinator.join() } + runCurrent() + assertThat(nextJoin.isCompleted).isFalse() + gate.complete(Unit) + assertThat(nextJoin.await()).isInstanceOf(Success::class.java) + } + actualLifecycle.leave() + runCurrent() + coVerify(exactly = 4) { mockSession.connectInternal() } + assertThat(providerJobs.distinct()).hasSize(4) + assertThat(providerJobs.all { it.isCancelled }).isTrue() + } + @Test fun `join succeeds and returns the connected session`() = runTest(testDispatcher) { stubJoinCall(Success(mockJoinResponse)) @@ -379,7 +447,7 @@ class CallJoinCoordinatorTest { val joinAnalytics = callAnalytics.joinAnalytics verify(exactly = 2) { joinAnalytics.onJoinFunctionStart() } verify(exactly = 1) { callAnalytics.mediaPermissionObserver.mediaPermissionStatus() } - verify(exactly = 1) { lifecycle.resetLeaveGuard() } + coVerify(exactly = 2) { lifecycle.prepareForJoin() } verify { state.callJoinInterceptor = interceptor } verify(exactly = 0) { state.callJoinInterceptor = null } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManagerTest.kt new file mode 100644 index 00000000000..a4775a289bf --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManagerTest.kt @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.components + +import io.getstream.video.android.core.CallState +import io.getstream.video.android.core.RealtimeConnection +import io.getstream.video.android.core.StreamVideoClient +import io.getstream.video.android.core.call.Restartable +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.scope.ScopeProviderImpl +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CallLifecycleManagerTest { + private class Fixture(test: TestScope) { + val session = mockk(relaxed = true) + val sessions = CallSessionManager().apply { setActiveSession(this@Fixture.session) } + val state = mockk(relaxed = true) + var restarts = 0 + var shutdowns = 0 + private val parent = test.backgroundScope + val scopeProvider = ScopeProviderImpl(parent, "default:test-call") + val scope: CoroutineScope get() = scopeProvider + init { + every { state._connection } returns MutableStateFlow(RealtimeConnection.PreJoin) + } + private val client = mockk(relaxed = true).also { + every { it.scope } returns parent + } + val lifecycle = CallLifecycleManager( + clientImpl = client, + sessionManager = sessions, + callRegistry = mockk(relaxed = true), + shutDownJobs = { + shutdowns++ + scopeProvider.cleanup() + }, + restartCoordinator = CallRestartCoordinator( + scopeProvider = Restartable { + restarts++ + scopeProvider.restart() + }, + restartables = emptyList(), + ), + state = state, + callAnalytics = mockk(relaxed = true), + statsReporter = mockk(relaxed = true), + media = mockk(relaxed = true), + iceMonitor = mockk(relaxed = true), + sessionMonitor = { mockk(relaxed = true) }, + connectivityMonitor = { mockk(relaxed = true) }, + type = "default", + id = "test-call", + ) + } + + @Test + fun `immediate concurrent rejoins wait for cleanup and restart only once`() = runTest { + val fixture = Fixture(this) + val leaveEvent = CompletableDeferred() + coEvery { fixture.session.sendLeaveEvent(any()) } coAnswers { leaveEvent.await() } + fixture.lifecycle.leave() + fixture.lifecycle.leave() + val first = async { fixture.lifecycle.prepareForJoin() } + val second = async { fixture.lifecycle.prepareForJoin() } + runCurrent() + assertFalse(first.isCompleted) + assertFalse(second.isCompleted) + assertEquals(0, fixture.restarts) + leaveEvent.complete(Unit) + first.await() + second.await() + assertEquals(1, fixture.shutdowns) + assertEquals(1, fixture.restarts) + assertFalse(fixture.lifecycle.isDestroyed) + } + + @Test + fun `rejoin waits for the cancelled call producers to finish their finalizers`() = runTest { + val fixture = Fixture(this) + val finished = CompletableDeferred() + fixture.scope.launch { + try { + awaitCancellation() + } finally { + withContext(NonCancellable) { finished.await() } + } + } + runCurrent() + fixture.lifecycle.leave() + val rejoin = async { fixture.lifecycle.prepareForJoin() } + runCurrent() + assertFalse(rejoin.isCompleted) + assertEquals(0, fixture.restarts) + finished.complete(Unit) + rejoin.await() + assertEquals(1, fixture.restarts) + } + + @Test + fun `repeated leave join cycles clean up each generation`() = runTest { + val fixture = Fixture(this) + repeat(3) { cycle -> + fixture.sessions.setActiveSession(fixture.session) + fixture.lifecycle.leave() + fixture.lifecycle.prepareForJoin() + assertEquals(cycle + 1, fixture.shutdowns) + assertEquals(cycle + 1, fixture.restarts) + assertTrue(fixture.scope.coroutineContext[Job]!!.isActive) + } + } + + @Test + fun `leave without another join releases resources without restarting producers`() = runTest { + val fixture = Fixture(this) + fixture.lifecycle.leave() + runCurrent() + assertEquals(1, fixture.shutdowns) + assertEquals(0, fixture.restarts) + assertTrue(fixture.scope.coroutineContext[Job]!!.isCancelled) + verify { fixture.state.cleanup() } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt index 50a2870a594..586012fb782 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt @@ -70,7 +70,7 @@ class CallMediaManagerTest { type = "default", id = "call-id", clientImpl = clientImpl, - scope = testScope, + scopeProvider = { testScope }, state = state, sessionManager = sessionManager, eglBase = { mockk(relaxed = true) }, @@ -101,9 +101,38 @@ class CallMediaManagerTest { verify { mediaManager.screenShare.disable(fromUser = true) } } + @Test + fun `rejoin restores the camera and microphone choices from before leave`() { + val cameraStatus = MutableStateFlow(DeviceStatus.Enabled) + val microphoneStatus = MutableStateFlow(DeviceStatus.Disabled) + every { mediaManager.camera.status } returns cameraStatus + every { mediaManager.microphone.status } returns microphoneStatus + every { mediaManager.camera.disable(any()) } answers { cameraStatus.value = DeviceStatus.Disabled } + val manager = manager() + manager.mediaManager + manager.disableLocalCapture() + manager.cleanup() + manager.restart() + manager.updateMediaManagerFromSettings(mockk(relaxed = true)) + verify { mediaManager.prepareForJoin() } + verify { mediaManager.camera.setEnabled(true) } + verify { mediaManager.microphone.setEnabled(false) } + } + + @Test + fun `leaving an unused call does not create media resources`() { + val manager = manager() + manager.disableLocalCapture() + manager.cleanup() + verify(exactly = 0) { mediaManager.cleanup() } + verify(exactly = 0) { mediaManager.camera } + } + @Test fun `cleanup delegates to the media manager`() { - manager().cleanup() + val manager = manager() + manager.mediaManager + manager.cleanup() verify { mediaManager.cleanup() } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinatorTest.kt new file mode 100644 index 00000000000..3798c2df51c --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinatorTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.components + +import io.getstream.video.android.core.call.Restartable +import org.junit.Test +import kotlin.test.assertEquals + +class CallRestartCoordinatorTest { + + @Test + fun `restart recreates scope before restarting components and completing bookkeeping`() { + val events = mutableListOf() + val coordinator = CallRestartCoordinator( + scopeProvider = Restartable { events += "scope" }, + restartables = listOf( + Restartable { events += "state" }, + Restartable { events += "media" }, + ), + onRestarted = { events += "bookkeeping" }, + ) + + coordinator.restart() + + assertEquals(listOf("scope", "state", "media", "bookkeeping"), events) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt new file mode 100644 index 00000000000..41bc4b603f2 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt @@ -0,0 +1,107 @@ +/* + * 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.scope + +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class RestartableStateFlowTest { + @Test + fun `provider restart moves production without replacing collectors`() = runTest { + val scopeProvider = ScopeProviderImpl(backgroundScope, "default:test-call") + val upstream = MutableStateFlow(1) + val state = RestartableStateFlow( + upstream = upstream, + scopeProvider = scopeProvider, + initialValue = 0, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 0), + ) + val values = mutableListOf() + val collector = backgroundScope.launch { state.collect(values::add) } + runCurrent() + assertEquals(1, state.value) + + scopeProvider.cleanup()?.join() + upstream.value = 2 + runCurrent() + assertEquals(1, state.value) + + scopeProvider.restart() + runCurrent() + + assertEquals(2, state.value) + assertFalse(collector.isCancelled) + assertEquals(listOf(0, 1, 0, 2), values) + } + + @Test + fun `while subscribed starts and stops upstream using actual subscriber count`() = runTest { + val scope = ScopeProviderImpl(backgroundScope, "default:test-call") + var active = 0 + val state = flow { + active++ + try { + emit(1) + awaitCancellation() + } finally { active-- } + }.restartableStateIn(scope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 0), 0) + runCurrent() + assertEquals(0, active) + val collector = backgroundScope.launch { state.collect {} } + runCurrent() + assertEquals(1, active) + collector.cancelAndJoin() + runCurrent() + assertEquals(0, active) + } + + @Test + fun `detached launches cannot run without an owner`() = runTest { + val scope = ScopeProviderImpl(backgroundScope, "default:test-call") + scope.cleanup()?.join() + var ran = false + val job = scope.launch { ran = true } + job.join() + assertFalse(ran) + } + + @Test + fun `replay expiration restores initial value`() = runTest { + val scope = ScopeProviderImpl(backgroundScope, "default:test-call") + val upstream = MutableStateFlow(1) + val state = upstream.restartableStateIn( + scope, + SharingStarted.WhileSubscribed(replayExpirationMillis = 0), + 0, + ) + val collector = backgroundScope.launch { state.collect {} } + runCurrent() + assertEquals(1, state.value) + collector.cancelAndJoin() + runCurrent() + assertEquals(0, state.value) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt index 74b7e00b852..b34079fa21e 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt @@ -18,11 +18,13 @@ package io.getstream.video.android.core.call.scope import io.getstream.video.android.core.socket.common.scope.ClientScope import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before @@ -31,12 +33,12 @@ import org.junit.Test class ScopeProviderTest { private lateinit var clientScope: ClientScope - private lateinit var scopeProvider: ScopeProvider + private lateinit var scopeProvider: ScopeProviderImpl @Before fun setup() { clientScope = ClientScope() - scopeProvider = ScopeProviderImpl(clientScope) + scopeProvider = ScopeProviderImpl(clientScope, "default:test-call") } @After @@ -124,4 +126,21 @@ class ScopeProviderTest { assertNotNull(scope1) assertNotNull(scope2) } + + @Test + fun `restart creates a new call job and allows new RTC scopes`() = runTest { + val firstCallJob = scopeProvider.coroutineContext[Job]!! + scopeProvider.getRtcSessionScope(SupervisorJob(), "test-call-1") + + scopeProvider.cleanup()?.join() + assertTrue(firstCallJob.isCancelled) + + scopeProvider.restart() + val secondCallJob = scopeProvider.coroutineContext[Job]!! + + assertEquals(2, scopeProvider.debugInstanceId) + assertNotEquals(firstCallJob, secondCallJob) + assertTrue(secondCallJob.isActive) + assertNotNull(scopeProvider.getRtcSessionScope(SupervisorJob(), "test-call-1")) + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/e2ee/CallE2EETest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/e2ee/CallE2EETest.kt index 3b10b2079b7..c6dc72e36fe 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/e2ee/CallE2EETest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/e2ee/CallE2EETest.kt @@ -40,6 +40,7 @@ import io.mockk.impl.annotations.RelaxedMockK import io.mockk.mockk import io.mockk.unmockkAll import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -93,6 +94,8 @@ class CallE2EETest { } every { mockClientImpl.scope } returns testScope as CoroutineScope every { mockClientImpl.apiKey } returns "test-api-key" + every { mockClientImpl.state.activeCall } returns MutableStateFlow(null) + every { mockClientImpl.state.ringingCall } returns MutableStateFlow(null) // Keeps joinRequest off the success path, which would try to apply a mocked response. coEvery { mockClientImpl.joinCall( diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/ReconnectEscalationTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/ReconnectEscalationTest.kt index 92d64363040..39caad0cb4c 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/ReconnectEscalationTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/rtc/ReconnectEscalationTest.kt @@ -89,6 +89,8 @@ class ReconnectEscalationTest { every { mockClientImpl.scope } returns testScope as CoroutineScope every { mockClientImpl.leaveAfterDisconnectSeconds } returns 120L every { mockClientImpl.apiKey } returns "test-api-key" + every { mockClientImpl.state.activeCall } returns MutableStateFlow(null) + every { mockClientImpl.state.ringingCall } returns MutableStateFlow(null) val user = User(id = "test-user", role = "user") diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsChannelFlowRegressionTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsChannelFlowRegressionTest.kt index 5203001154f..9c2d81a46f3 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsChannelFlowRegressionTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsChannelFlowRegressionTest.kt @@ -71,7 +71,7 @@ class SortedParticipantsChannelFlowRegressionTest { val pinned: StateFlow> = MutableStateFlow(emptyMap()) - val sut = SortedParticipantsState(scope, call, participants, pinned) + val sut = SortedParticipantsState({ scope }, call, participants, pinned) val sortedFlow = sut.sortedParticipants diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsStateTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsStateTest.kt index ea71b70cfb5..6aecdc8d5e1 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsStateTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsStateTest.kt @@ -251,7 +251,7 @@ class SortedParticipantsStateTest { MutableStateFlow>(emptyMap()) val pinned: MutableStateFlow> = MutableStateFlow(emptyMap()) - val state = SortedParticipantsState(scope, call, participants, pinned) + val state = SortedParticipantsState({ scope }, call, participants, pinned) return SutHandle( scope = scope, callActions = callActions, From b0abc38ede491c352449153497be1efc8ea13539 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Fri, 23 Oct 2026 13:55:56 +0530 Subject: [PATCH 02/20] 1. replace restartableStateIn with stateIn --- .../io/getstream/video/android/core/CallState.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 c5a27a52290..1b30b198545 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 @@ -245,7 +245,7 @@ public class CallState internal constructor( @InternalStreamVideoApi val scope: CoroutineScope = scopeProvider - private fun Flow.restartableStateIn( + private fun Flow.stateIn( started: SharingStarted, initialValue: T, ): StateFlow = RestartableStateFlow( @@ -374,7 +374,7 @@ public class CallState internal constructor( combined.putAll(server) combined.putAll(local) // local overrides server on key collision combined.toMap() - }.restartableStateIn(SharingStarted.Eagerly, emptyMap()) + }.stateIn(SharingStarted.Eagerly, emptyMap()) internal val _pinnedParticipants: StateFlow> = _pinnedParticipantsDetailed.mapState { detailed -> @@ -487,7 +487,7 @@ public class CallState internal constructor( val livestream: StateFlow = livestreamFlow.debounce( 1000, - ).restartableStateIn(SharingStarted.WhileSubscribed(10_000L), null) + ).stateIn(SharingStarted.WhileSubscribed(10_000L), null) private val _sortedParticipantsState = SortedParticipantsState( scopeProvider = { scopeProvider }, @@ -592,11 +592,11 @@ 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)) } - .restartableStateIn(SharingStarted.WhileSubscribed(10000L), null) + .stateIn(SharingStarted.WhileSubscribed(10000L), null) /** how many milliseconds the call has been running, null if the call didn't start yet */ public val durationInMs: StateFlow = - _durationInMs.restartableStateIn(SharingStarted.WhileSubscribed(10000L), null) + _durationInMs.stateIn(SharingStarted.WhileSubscribed(10000L), null) /** how many milliseconds the call has been running in the simple date format. */ public val durationInDateFormat: StateFlow = durationInMs.mapState { durationInMs -> @@ -659,7 +659,7 @@ public class CallState internal constructor( emit(duration) } } - }.distinctUntilChanged().restartableStateIn(SharingStarted.WhileSubscribed(10000L), null) + }.distinctUntilChanged().stateIn(SharingStarted.WhileSubscribed(10000L), null) /** * How long the call has been live for, represented as [Duration], or null if the call hasn't been live yet. From 8027b664b6b5ece353b51fb7bac7cb71ddcefe9d Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Fri, 23 Oct 2026 15:14:31 +0530 Subject: [PATCH 03/20] 1.refactor --- .../io/getstream/video/android/core/Call.kt | 7 +++-- .../getstream/video/android/core/CallState.kt | 4 +-- .../call/components/CallJoinCoordinator.kt | 1 - .../core/call/components/CallMediaManager.kt | 6 ++--- .../core/call/scope/ScopeProviderImpl.kt | 4 +-- .../call/scope/SynchronizedScopeGeneration.kt | 26 +++++++++---------- .../call/components/CallMediaManagerTest.kt | 2 +- 7 files changed, 24 insertions(+), 26 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 3acf6809dd5..40d91862519 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 @@ -370,7 +370,7 @@ public class Call( type = type, id = id, clientImpl = clientImpl, - scopeProvider = { scope }, + coroutineScope = scopeProvider, state = state, sessionManager = sessionManager, eglBase = { eglBase }, @@ -581,13 +581,12 @@ public class Call( private var autoOnApplied: Boolean = false init { - state.startScopeJobs() media.startAudioLevelMonitoring() - observeNoiseCancellationSignalTarget() - observeNoiseCancellationPolicy() powerManager = safeCallWithDefault(null) { clientImpl.context.getSystemService(POWER_SERVICE) as? PowerManager } + observeNoiseCancellationSignalTarget() + observeNoiseCancellationPolicy() } /** Basic crud operations */ 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 1b30b198545..03f3659f03a 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 @@ -2096,10 +2096,10 @@ public class CallState internal constructor( _notificationIdFlow.value = null _serviceRoute.value = ServiceRoute.UNDECIDED _sortedParticipantsState.restart() - startScopeJobs() + restartScopeObservers() } - internal fun startScopeJobs() { + private fun restartScopeObservers() { participantVisibilityFlow?.let { updateParticipantVisibilityFlow(it) } jetpackTelecomRepository?.let { observeTelecomHold(it) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt index 90b12817265..ce3dec0ef86 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -325,7 +325,6 @@ internal class CallJoinCoordinator( video: Boolean = isVideoEnabled(), callJoinInterceptor: CallJoinInterceptor? = null, ): Result { - lifecycle.prepareForJoin() logger.d { "[joinAndRing] #ringing; #track; members: $members, video: $video" } state.toggleJoinAndRingProgress(true) return join( diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt index db2418b0f62..f519058c009 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -55,7 +55,7 @@ internal class CallMediaManager( private val type: String, private val id: String, private val clientImpl: StreamVideoClient, - private val scopeProvider: () -> CoroutineScope, + private val coroutineScope: CoroutineScope, private val state: CallState, private val sessionManager: CallSessionManager, private val eglBase: () -> EglBase, @@ -138,7 +138,7 @@ internal class CallMediaManager( /** Starts streaming smoothed microphone audio levels into [localMicrophoneAudioLevel]. */ fun startAudioLevelMonitoring() { audioLevelMonitoringJob?.cancel() - audioLevelMonitoringJob = scopeProvider().launch { + audioLevelMonitoringJob = coroutineScope.launch { soundInputProcessor.currentAudioLevel.collect { audioLevelOutputHelper.rampToValue(it) } @@ -292,7 +292,7 @@ internal class CallMediaManager( microphone.select(deviceBeforeHeadset) } } - }.launchIn(scopeProvider()) + }.launchIn(coroutineScope) } fun startScreenSharing( 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 f8ee4252a7c..839581e5566 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 @@ -44,7 +44,7 @@ internal class ScopeProviderImpl( private val logger by taggedLogger("ScopeProvider:$callCid") private val generation = SynchronizedScopeGeneration( - createCallJob = { SupervisorJob(coroutineScope.coroutineContext[Job]) }, + callJobFactory = { SupervisorJob(coroutineScope.coroutineContext[Job]) }, ) private val restartListeners = CopyOnWriteArrayList<() -> Unit>() @@ -109,6 +109,6 @@ internal class ScopeProviderImpl( restartListeners += listener } - private fun SynchronizedScopeGeneration.Generation.logPrefix(): String = + private fun SynchronizedScopeGeneration.CallScopeGeneration.logPrefix(): String = "[debug-instance-$debugInstanceId]" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt index bbb5a640c59..54884969328 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt @@ -28,28 +28,28 @@ import java.util.concurrent.atomic.AtomicReference * and applying cleanup and restart lifecycle operations. */ internal class SynchronizedScopeGeneration( - private val createCallJob: () -> CompletableJob, + private val callJobFactory: () -> CompletableJob, ) { - internal data class Generation( + internal data class CallScopeGeneration( val debugInstanceId: Int, val callJob: CompletableJob, ) internal data class ExecutorAccess( - val generation: Generation, + val generation: CallScopeGeneration, val executor: ExecutorService, val created: Boolean, ) internal data class CleanupAccess( - val generation: Generation, + val generation: CallScopeGeneration, val executor: ExecutorService?, val cleanedUpNow: Boolean, ) - private class SynchronizedGeneration( - val generation: Generation, + private class CallScopeGenerationState( + val generation: CallScopeGeneration, ) { val lock = Any() var executor: ExecutorService? = null @@ -60,9 +60,9 @@ internal class SynchronizedScopeGeneration( private val debugInstanceCounter = AtomicInteger(0) private val current = AtomicReference(createGeneration()) - internal fun current(): Generation = current.get().generation + internal fun current(): CallScopeGeneration = current.get().generation - internal fun requireActive(): Generation { + internal fun requireActive(): CallScopeGeneration { val currentGeneration = current.get() return synchronized(currentGeneration.lock) { checkActive(currentGeneration) @@ -117,7 +117,7 @@ internal class SynchronizedScopeGeneration( } } - internal fun restart(): Generation = synchronized(restartLock) { + internal fun restart(): CallScopeGeneration = synchronized(restartLock) { val currentGeneration = current.get() synchronized(currentGeneration.lock) { check(currentGeneration.isCleanedUp) { @@ -127,14 +127,14 @@ internal class SynchronizedScopeGeneration( } } - private fun createGeneration(): SynchronizedGeneration = SynchronizedGeneration( - generation = Generation( + private fun createGeneration(): CallScopeGenerationState = CallScopeGenerationState( + generation = CallScopeGeneration( debugInstanceId = debugInstanceCounter.incrementAndGet(), - callJob = createCallJob(), + callJob = callJobFactory(), ), ) - private fun checkActive(generation: SynchronizedGeneration) { + private fun checkActive(generation: CallScopeGenerationState) { check(!generation.isCleanedUp) { "ScopeProvider has been cleaned up" } } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt index 586012fb782..b14c8863cce 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallMediaManagerTest.kt @@ -70,7 +70,7 @@ class CallMediaManagerTest { type = "default", id = "call-id", clientImpl = clientImpl, - scopeProvider = { testScope }, + coroutineScope = testScope, state = state, sessionManager = sessionManager, eglBase = { mockk(relaxed = true) }, From 44e37084f3c39f727eb21356b7af96284b70b794 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Mon, 26 Oct 2026 13:28:04 +0530 Subject: [PATCH 04/20] 1. Simplify ScopeProviderImpl --- .../io/getstream/video/android/core/Call.kt | 7 +++-- .../getstream/video/android/core/CallState.kt | 18 ++++++------- .../core/call/scope/RestartableStateFlow.kt | 2 +- .../android/core/call/scope/ScopeProvider.kt | 5 +++- .../core/call/scope/ScopeProviderImpl.kt | 8 +++--- .../call/scope/SynchronizedScopeGeneration.kt | 27 ++++++++++++------- .../components/CallJoinCoordinatorTest.kt | 2 +- .../components/CallLifecycleManagerTest.kt | 2 +- .../call/scope/RestartableStateFlowTest.kt | 2 +- .../core/call/scope/ScopeProviderTest.kt | 10 ++++--- 10 files changed, 50 insertions(+), 33 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 40d91862519..41c3305fb56 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 @@ -146,7 +146,10 @@ public class Call( private val logger by taggedLogger("Call:$type:$id") internal var powerManager: PowerManager? = null - internal val scope: CoroutineScope = scopeProvider + internal val scope: CoroutineScope = object : CoroutineScope { + override val coroutineContext + get() = scopeProvider.callScope.coroutineContext + } /** Delegate that owns the live RTC session state and reconnect bookkeeping. */ private val sessionManager = CallSessionManager() @@ -370,7 +373,7 @@ public class Call( type = type, id = id, clientImpl = clientImpl, - coroutineScope = scopeProvider, + coroutineScope = scope, state = state, sessionManager = sessionManager, eglBase = { eglBase }, 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 03f3659f03a..dee1c2679d6 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 @@ -243,7 +243,7 @@ public class CallState internal constructor( private var participantVisibilityFlow: Flow>? = null @InternalStreamVideoApi - val scope: CoroutineScope = scopeProvider + val scope: CoroutineScope = call.scope private fun Flow.stateIn( started: SharingStarted, @@ -490,7 +490,7 @@ public class CallState internal constructor( ).stateIn(SharingStarted.WhileSubscribed(10_000L), null) private val _sortedParticipantsState = SortedParticipantsState( - scopeProvider = { scopeProvider }, + scopeProvider = { scopeProvider.callScope }, call = call, participants = _participants, pinnedParticipantsDetailed = _pinnedParticipantsDetailed, @@ -938,7 +938,7 @@ public class CallState internal constructor( } is LocalCallMissedEvent -> { - scopeProvider.launch { + scopeProvider.callScope.launch { val newRejectedBySet = _rejectedBy.value.toMutableSet() StreamVideo.instanceOrNull()?.let { newRejectedBySet.add(it.user.id) @@ -1355,7 +1355,7 @@ public class CallState internal constructor( -> closedCaptionManager.handleEvent(event) is CallModerationBlurEvent -> { - scopeProvider.launch { + scopeProvider.callScope.launch { val callServiceConfig = StreamVideo.instanceOrNull()?.state?.callConfigRegistry?.get(call.type) ?: CallServiceConfig() if (callServiceConfig.moderationConfig.videoModerationConfig.enable) { call.state.moderationManager.applyVideoModeration() @@ -1567,7 +1567,7 @@ public class CallState internal constructor( if (!shouldJoin) return false call.setJoinSource(source) - autoJoiningCall = scopeProvider.launch { + autoJoiningCall = scopeProvider.callScope.launch { // errors are handled inside the join function val joinResult = call.join() autoJoiningCall = null @@ -1634,7 +1634,7 @@ public class CallState internal constructor( fun markSpeakingAsMuted() { _speakingWhileMuted.value = true speakingWhileMutedResetJob?.cancel() - speakingWhileMutedResetJob = scopeProvider.launch { + speakingWhileMutedResetJob = scopeProvider.callScope.launch { delay(2000) _speakingWhileMuted.value = false } @@ -1774,7 +1774,7 @@ public class CallState internal constructor( } else { ParticipantState( sessionId = sessionId, - scope = scopeProvider, + scope = scope, callActions = callActions, initialUserId = userId, source = source, @@ -1993,7 +1993,7 @@ public class CallState internal constructor( participantsVisibilityMonitor?.cancel() if (flow != null) { - participantsVisibilityMonitor = scopeProvider.launch { + participantsVisibilityMonitor = scopeProvider.callScope.launch { flow.collectLatest { visibleParticipantIds -> internalParticipants.forEach { if (visibleParticipantIds.contains(it.key)) { @@ -2061,7 +2061,7 @@ public class CallState internal constructor( private fun observeTelecomHold(repo: JetpackTelecomRepository) { telecomHoldObserverJob?.cancel() - telecomHoldObserverJob = scopeProvider.launch(DispatcherProvider.Default) { + telecomHoldObserverJob = scopeProvider.callScope.launch(DispatcherProvider.Default) { repo.currentCall .map { (it as? TelecomCall.Registered)?.isOnHold == true } .distinctUntilChanged() diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt index 7d992848a02..fb52e0f5c63 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt @@ -54,7 +54,7 @@ internal class RestartableStateFlow( // TODO: Revisit whether this manual SharingStarted implementation can be simplified. // It currently uses the exposed StateFlow's subscriber count to preserve stateIn's // WhileSubscribed and replay-reset behavior across scope restarts. - producerJob = scopeProvider.launch { + producerJob = scopeProvider.callScope.launch { started.command(state.subscriptionCount).distinctUntilChanged().collectLatest { command -> when (command) { SharingCommand.START -> upstream.collect { state.value = it } 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 e0e5a576968..d5530a39286 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 @@ -22,7 +22,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job /** Owns a Call's coroutine generations and creates the scopes used by its RTC session. */ -internal interface ScopeProvider : CoroutineScope, Restartable { +internal interface ScopeProvider : Restartable { + /** The coroutine scope for the current Call generation. */ + val callScope: CoroutineScope + /** * Provides the main coroutine scope for RTC session operations. * This scope is based on the client's main scope with a supervisor job. 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 839581e5566..16a1623bb64 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 @@ -21,11 +21,9 @@ import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.Executors -import kotlin.coroutines.CoroutineContext import kotlin.jvm.Throws /** @@ -44,14 +42,14 @@ internal class ScopeProviderImpl( private val logger by taggedLogger("ScopeProvider:$callCid") private val generation = SynchronizedScopeGeneration( - callJobFactory = { SupervisorJob(coroutineScope.coroutineContext[Job]) }, + parentCoroutineContext = coroutineScope.coroutineContext, ) private val restartListeners = CopyOnWriteArrayList<() -> Unit>() internal val debugInstanceId: Int get() = generation.current().debugInstanceId - override val coroutineContext: CoroutineContext - get() = coroutineScope.coroutineContext + generation.current().callJob + override val callScope: CoroutineScope + get() = generation.current().callScope @Throws(IllegalStateException::class) override fun getCoroutineScope(supervisorJob: CompletableJob): CoroutineScope { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt index 54884969328..b12a1f427d7 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt @@ -17,22 +17,27 @@ package io.getstream.video.android.core.call.scope import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob import java.util.concurrent.ExecutorService import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.CoroutineContext /** * Provides synchronized access to the resources that change between Call scope generations. * - * Synchronization is isolated here so [ScopeProviderImpl] can focus on creating coroutine scopes - * and applying cleanup and restart lifecycle operations. + * Synchronization and Call scope creation are isolated here so [ScopeProviderImpl] can focus on + * exposing scopes and applying cleanup and restart lifecycle operations. */ internal class SynchronizedScopeGeneration( - private val callJobFactory: () -> CompletableJob, + private val parentCoroutineContext: CoroutineContext, ) { internal data class CallScopeGeneration( val debugInstanceId: Int, + val callScope: CoroutineScope, val callJob: CompletableJob, ) @@ -127,12 +132,16 @@ internal class SynchronizedScopeGeneration( } } - private fun createGeneration(): CallScopeGenerationState = CallScopeGenerationState( - generation = CallScopeGeneration( - debugInstanceId = debugInstanceCounter.incrementAndGet(), - callJob = callJobFactory(), - ), - ) + private fun createGeneration(): CallScopeGenerationState { + val callJob = SupervisorJob(parentCoroutineContext[Job]) + return CallScopeGenerationState( + generation = CallScopeGeneration( + debugInstanceId = debugInstanceCounter.incrementAndGet(), + callScope = CoroutineScope(parentCoroutineContext + callJob), + callJob = callJob, + ), + ) + } private fun checkActive(generation: CallScopeGenerationState) { check(!generation.isCleanedUp) { "ScopeProvider has been cleaned up" } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt index e9dcbd42b7f..d665fac6e60 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -188,7 +188,7 @@ class CallJoinCoordinatorTest { lifecycle = actualLifecycle, sessionFactory = RtcSessionFactory { _, _, _, _, _, _, _ -> // RtcSession needs this provider on every join, including after cleanup. - providerJobs += provider.coroutineContext[Job]!! + providerJobs += provider.callScope.coroutineContext[Job]!! val job = SupervisorJob() provider.getRtcSessionScope(job, "test-call") job.cancel() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManagerTest.kt index a4775a289bf..928016149fe 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManagerTest.kt @@ -52,7 +52,7 @@ class CallLifecycleManagerTest { var shutdowns = 0 private val parent = test.backgroundScope val scopeProvider = ScopeProviderImpl(parent, "default:test-call") - val scope: CoroutineScope get() = scopeProvider + val scope: CoroutineScope get() = scopeProvider.callScope init { every { state._connection } returns MutableStateFlow(RealtimeConnection.PreJoin) } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt index 41bc4b603f2..6b8fc41966d 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt @@ -83,7 +83,7 @@ class RestartableStateFlowTest { val scope = ScopeProviderImpl(backgroundScope, "default:test-call") scope.cleanup()?.join() var ran = false - val job = scope.launch { ran = true } + val job = scope.callScope.launch { ran = true } job.join() assertFalse(ran) } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt index b34079fa21e..2216da23a45 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt @@ -26,6 +26,7 @@ import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -128,17 +129,20 @@ class ScopeProviderTest { } @Test - fun `restart creates a new call job and allows new RTC scopes`() = runTest { - val firstCallJob = scopeProvider.coroutineContext[Job]!! + fun `restart creates a new call scope and allows new RTC scopes`() = runTest { + val firstCallScope = scopeProvider.callScope + val firstCallJob = firstCallScope.coroutineContext[Job]!! scopeProvider.getRtcSessionScope(SupervisorJob(), "test-call-1") scopeProvider.cleanup()?.join() assertTrue(firstCallJob.isCancelled) scopeProvider.restart() - val secondCallJob = scopeProvider.coroutineContext[Job]!! + val secondCallScope = scopeProvider.callScope + val secondCallJob = secondCallScope.coroutineContext[Job]!! assertEquals(2, scopeProvider.debugInstanceId) + assertNotSame(firstCallScope, secondCallScope) assertNotEquals(firstCallJob, secondCallJob) assertTrue(secondCallJob.isActive) assertNotNull(scopeProvider.getRtcSessionScope(SupervisorJob(), "test-call-1")) From d40a30733d0052621aaed94480d97d6da54acdbc Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Mon, 26 Oct 2026 13:36:45 +0530 Subject: [PATCH 05/20] Simplify CallScopeGenerationManager --- .../call/scope/CallScopeGenerationManager.kt | 117 ++++++++++++++ .../core/call/scope/ScopeProviderImpl.kt | 45 ++---- .../call/scope/SynchronizedScopeGeneration.kt | 149 ------------------ 3 files changed, 134 insertions(+), 177 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt delete mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt new file mode 100644 index 00000000000..54a19fb71ac --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.scope + +import kotlinx.coroutines.CompletableJob +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import kotlin.coroutines.CoroutineContext + +/** Manages the state and lifecycle transitions of the current Call coroutine scope generation. */ +internal class CallScopeGenerationManager( + private val parentCoroutineContext: CoroutineContext, +) { + + private class GenerationState( + val debugInstanceId: Int, + val callScope: CoroutineScope, + val callJob: CompletableJob, + ) { + var executor: ExecutorService? = null + var isCleanedUp: Boolean = false + } + + /** Resources detached from a generation when cleanup changes it to the cleaned state. */ + internal data class CleanupResult( + val debugInstanceId: Int, + val callJob: Job, + val executor: ExecutorService?, + val cleanedUpNow: Boolean, + ) + + private val lifecycleLock = Any() + private val debugInstanceCounter = AtomicInteger(0) + private val current = AtomicReference(createGeneration()) + + internal val currentCallScope: CoroutineScope + get() = current.get().callScope + + internal val currentDebugInstanceId: Int + get() = current.get().debugInstanceId + + internal fun requireActive(): Int = synchronized(lifecycleLock) { + current.get().also(::checkActive).debugInstanceId + } + + internal fun getOrCreateExecutor( + callId: String, + ): ExecutorService = synchronized(lifecycleLock) { + val generation = current.get().also(::checkActive) + generation.executor ?: Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "rtc-call-$callId") + }.also { + generation.executor = it + } + } + + internal fun cleanup(): CleanupResult = synchronized(lifecycleLock) { + val generation = current.get() + if (generation.isCleanedUp) { + CleanupResult( + debugInstanceId = generation.debugInstanceId, + callJob = generation.callJob, + executor = null, + cleanedUpNow = false, + ) + } else { + generation.isCleanedUp = true + val executor = generation.executor + generation.executor = null + CleanupResult( + debugInstanceId = generation.debugInstanceId, + callJob = generation.callJob, + executor = executor, + cleanedUpNow = true, + ) + } + } + + internal fun restart(): Int = synchronized(lifecycleLock) { + check(current.get().isCleanedUp) { + "ScopeProvider cannot restart before its current generation is cleaned up" + } + createGeneration().also(current::set).debugInstanceId + } + + private fun createGeneration(): GenerationState { + val callJob = SupervisorJob(parentCoroutineContext[Job]) + return GenerationState( + debugInstanceId = debugInstanceCounter.incrementAndGet(), + callScope = CoroutineScope(parentCoroutineContext + callJob), + callJob = callJob, + ) + } + + private fun checkActive(generation: GenerationState) { + check(!generation.isCleanedUp) { "ScopeProvider has been cleaned up" } + } +} 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 16a1623bb64..b708f95e959 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 @@ -23,7 +23,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.asCoroutineDispatcher import java.util.concurrent.CopyOnWriteArrayList -import java.util.concurrent.Executors import kotlin.jvm.Throws /** @@ -41,20 +40,20 @@ internal class ScopeProviderImpl( ) : ScopeProvider { private val logger by taggedLogger("ScopeProvider:$callCid") - private val generation = SynchronizedScopeGeneration( + private val generationManager = CallScopeGenerationManager( parentCoroutineContext = coroutineScope.coroutineContext, ) private val restartListeners = CopyOnWriteArrayList<() -> Unit>() - internal val debugInstanceId: Int get() = generation.current().debugInstanceId + internal val debugInstanceId: Int get() = generationManager.currentDebugInstanceId override val callScope: CoroutineScope - get() = generation.current().callScope + get() = generationManager.currentCallScope @Throws(IllegalStateException::class) override fun getCoroutineScope(supervisorJob: CompletableJob): CoroutineScope { - val currentGeneration = generation.requireActive() - logger.d { "${currentGeneration.logPrefix()} Creating coroutine scope for RTC session main" } + val debugInstanceId = generationManager.requireActive() + logger.d { "${debugInstanceId.logPrefix()} Creating coroutine scope for RTC session main" } return CoroutineScope( coroutineScope.coroutineContext + supervisorJob + @@ -64,42 +63,33 @@ internal class ScopeProviderImpl( @Throws(IllegalStateException::class) override fun getRtcSessionScope(supervisorJob: CompletableJob, callId: String): CoroutineScope { - val executorAccess = generation.getOrCreateExecutor { - Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "rtc-call-$callId") - } - } - if (executorAccess.created) { - logger.d { - "${executorAccess.generation.logPrefix()} Creating new single thread executor for callId: $callId" - } - } + val executor = generationManager.getOrCreateExecutor(callId) logger.d { - "${executorAccess.generation.logPrefix()} Creating RTC session scope with dedicated executor for callId: $callId" + "${generationManager.currentDebugInstanceId.logPrefix()} Creating RTC session scope with dedicated executor for callId: $callId" } return CoroutineScope( coroutineScope.coroutineContext + supervisorJob + CoroutineName("rtc-session-coroutine") + - executorAccess.executor.asCoroutineDispatcher(), + executor.asCoroutineDispatcher(), ) } override fun cleanup(): Job? { - val cleanupAccess = generation.cleanup() - if (cleanupAccess.cleanedUpNow) { - cleanupAccess.generation.callJob.cancel() + val cleanupResult = generationManager.cleanup() + if (cleanupResult.cleanedUpNow) { + cleanupResult.callJob.cancel() logger.d { - "${cleanupAccess.generation.logPrefix()} Cleaning up ScopeProvider and shutting down executor" + "${cleanupResult.debugInstanceId.logPrefix()} Cleaning up ScopeProvider and shutting down executor" } - cleanupAccess.executor?.shutdown() + cleanupResult.executor?.shutdown() } - return cleanupAccess.generation.callJob + return cleanupResult.callJob } override fun restart() { - val nextGeneration = generation.restart() - logger.d { "${nextGeneration.logPrefix()} Restarted ScopeProvider" } + val debugInstanceId = generationManager.restart() + logger.d { "${debugInstanceId.logPrefix()} Restarted ScopeProvider" } restartListeners.forEach { it() } } @@ -107,6 +97,5 @@ internal class ScopeProviderImpl( restartListeners += listener } - private fun SynchronizedScopeGeneration.CallScopeGeneration.logPrefix(): String = - "[debug-instance-$debugInstanceId]" + private fun Int.logPrefix(): String = "[debug-instance-$this]" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt deleted file mode 100644 index b12a1f427d7..00000000000 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/SynchronizedScopeGeneration.kt +++ /dev/null @@ -1,149 +0,0 @@ -/* - * 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.scope - -import kotlinx.coroutines.CompletableJob -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import java.util.concurrent.ExecutorService -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicReference -import kotlin.coroutines.CoroutineContext - -/** - * Provides synchronized access to the resources that change between Call scope generations. - * - * Synchronization and Call scope creation are isolated here so [ScopeProviderImpl] can focus on - * exposing scopes and applying cleanup and restart lifecycle operations. - */ -internal class SynchronizedScopeGeneration( - private val parentCoroutineContext: CoroutineContext, -) { - - internal data class CallScopeGeneration( - val debugInstanceId: Int, - val callScope: CoroutineScope, - val callJob: CompletableJob, - ) - - internal data class ExecutorAccess( - val generation: CallScopeGeneration, - val executor: ExecutorService, - val created: Boolean, - ) - - internal data class CleanupAccess( - val generation: CallScopeGeneration, - val executor: ExecutorService?, - val cleanedUpNow: Boolean, - ) - - private class CallScopeGenerationState( - val generation: CallScopeGeneration, - ) { - val lock = Any() - var executor: ExecutorService? = null - var isCleanedUp: Boolean = false - } - - private val restartLock = Any() - private val debugInstanceCounter = AtomicInteger(0) - private val current = AtomicReference(createGeneration()) - - internal fun current(): CallScopeGeneration = current.get().generation - - internal fun requireActive(): CallScopeGeneration { - val currentGeneration = current.get() - return synchronized(currentGeneration.lock) { - checkActive(currentGeneration) - currentGeneration.generation - } - } - - internal fun getOrCreateExecutor( - factory: () -> ExecutorService, - ): ExecutorAccess { - val currentGeneration = current.get() - return synchronized(currentGeneration.lock) { - checkActive(currentGeneration) - val existingExecutor = currentGeneration.executor - if (existingExecutor != null) { - ExecutorAccess( - generation = currentGeneration.generation, - executor = existingExecutor, - created = false, - ) - } else { - val newExecutor = factory() - currentGeneration.executor = newExecutor - ExecutorAccess( - generation = currentGeneration.generation, - executor = newExecutor, - created = true, - ) - } - } - } - - internal fun cleanup(): CleanupAccess { - val currentGeneration = current.get() - return synchronized(currentGeneration.lock) { - if (currentGeneration.isCleanedUp) { - return@synchronized CleanupAccess( - generation = currentGeneration.generation, - executor = null, - cleanedUpNow = false, - ) - } - - currentGeneration.isCleanedUp = true - val executor = currentGeneration.executor - currentGeneration.executor = null - CleanupAccess( - generation = currentGeneration.generation, - executor = executor, - cleanedUpNow = true, - ) - } - } - - internal fun restart(): CallScopeGeneration = synchronized(restartLock) { - val currentGeneration = current.get() - synchronized(currentGeneration.lock) { - check(currentGeneration.isCleanedUp) { - "ScopeProvider cannot restart before its current generation is cleaned up" - } - createGeneration().also(current::set).generation - } - } - - private fun createGeneration(): CallScopeGenerationState { - val callJob = SupervisorJob(parentCoroutineContext[Job]) - return CallScopeGenerationState( - generation = CallScopeGeneration( - debugInstanceId = debugInstanceCounter.incrementAndGet(), - callScope = CoroutineScope(parentCoroutineContext + callJob), - callJob = callJob, - ), - ) - } - - private fun checkActive(generation: CallScopeGenerationState) { - check(!generation.isCleanedUp) { "ScopeProvider has been cleaned up" } - } -} From 98b946843e39808ea35b847869e297f1fa833796 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Mon, 26 Oct 2026 14:07:39 +0530 Subject: [PATCH 06/20] Scope restart no longer uses check() for the cleanup precondition. The original error message propagates to CallJoinCoordinator. onJoinFunctionStart() remains the first statement. Restart failures are reported through JoinAnalytics. Failed restarts do not clear cleanup state, allowing a later join to retry. The analytics attempt ID survives the successful restart. Added tests for propagation, analytics, retry state, and component ordering. --- .../io/getstream/video/android/core/Call.kt | 5 ++- .../core/analytics/call/CallAnalytics.kt | 7 ++- .../analytics/call/observer/JoinAnalytics.kt | 11 +++++ .../video/android/core/call/Restartable.kt | 6 ++- .../core/call/components/CallCleanupState.kt | 14 +++--- .../call/components/CallJoinCoordinator.kt | 13 +++++- .../call/components/CallLifecycleManager.kt | 3 +- .../core/call/components/CallMediaManager.kt | 5 ++- .../call/components/CallRestartCoordinator.kt | 15 +++++-- .../call/components/CallSessionManager.kt | 5 ++- .../call/scope/CallScopeGenerationManager.kt | 17 +++++-- .../core/call/scope/ScopeProviderImpl.kt | 13 ++++-- .../core/sorting/SortedParticipantsState.kt | 5 ++- .../core/analytics/call/CallAnalyticsTest.kt | 15 +++++++ .../call/observer/JoinAnalyticsTest.kt | 22 +++++++++ .../call/components/CallCleanupStateTest.kt | 45 +++++++++++++++++++ .../components/CallJoinCoordinatorTest.kt | 24 ++++++++++ .../components/CallRestartCoordinatorTest.kt | 42 +++++++++++++++-- .../core/call/scope/ScopeProviderTest.kt | 20 ++++++++- 19 files changed, 256 insertions(+), 31 deletions(-) create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallCleanupStateTest.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 41c3305fb56..b051a0b57cf 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 @@ -386,7 +386,10 @@ public class Call( restartables = listOf( sessionManager, callAnalytics, - Restartable(state::restart), + Restartable { + state.restart() + Result.Success(Unit) + }, media, ), onRestarted = { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt index 462b65f3786..3f4381144c6 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/CallAnalytics.kt @@ -18,6 +18,8 @@ package io.getstream.video.android.core.analytics.call import android.content.Context import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.result.Result.Success import io.getstream.video.android.core.CallLeaveReason import io.getstream.video.android.core.ParticipantState import io.getstream.video.android.core.RealtimeConnection @@ -99,13 +101,16 @@ internal class CallAnalytics( sfuAnalyticsStateHolder, ) - override fun restart() { + override fun restart(): Result { + val currentJoinAttemptId = joinAnalyticsStateHolder.state.value.joinStageAttemptId joinAnalyticsStateHolder.reset() + joinAnalyticsStateHolder.updateJoinStageAttemptId(currentJoinAttemptId) sfuAnalyticsStateHolder.updateSfuId("") sfuAnalyticsStateHolder.updateStageId("") sfuAnalyticsStateHolder.updateStage(Stage.NOT_STARTED) peerConnectionAnalytics.stateHolder.reset() resetAfterJoinSuccess() + return Success(Unit) } fun resetAfterJoinSuccess() { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalytics.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalytics.kt index 5c571c9a773..278ba634751 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalytics.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalytics.kt @@ -128,6 +128,17 @@ internal class JoinAnalytics( } } + /** Records a failure that prevents the Call from reaching the Coordinator join request. */ + fun onJoinPreparationError(failureCode: String, message: String) { + resetStage() + onJoinRequestStart(JoinReason.FirstAttempt) + onJoinRequestPermanentError( + retryCount = 0, + failureCode = failureCode, + message = message, + ) + } + /** * Records a terminal join failure after all retry attempts to connect to the Coordinator or * SFU have been exhausted. diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/Restartable.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/Restartable.kt index d7f7ae94f37..22be312fa74 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/Restartable.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/Restartable.kt @@ -16,8 +16,10 @@ package io.getstream.video.android.core.call +import io.getstream.result.Result + /** A component that can restore its own state for another Call lifecycle generation. */ internal fun interface Restartable { - /** Resets values and reinitialises resources owned by this component. */ - fun restart() + /** Resets owned state, returning a failure when the next lifecycle cannot be prepared. */ + fun restart(): Result } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt index 72dba53e744..59bb3313664 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt @@ -16,6 +16,8 @@ package io.getstream.video.android.core.call.components +import io.getstream.result.Result +import io.getstream.result.Result.Success import kotlinx.coroutines.Job /** Stores and synchronizes the cleanup state shared by leave, cleanup and the next join. */ @@ -63,7 +65,7 @@ internal class CallCleanupState { } /** Waits for cleanup and cancellation, then atomically restores the active lifecycle state. */ - suspend fun prepareForJoin(restart: () -> Unit) { + suspend fun prepareForJoin(restart: () -> Result): Result { while (true) { val pending = synchronized(lock) { PendingJobs(cleanupJob, cancelledCallJob) @@ -71,19 +73,21 @@ internal class CallCleanupState { pending.cleanup?.join() pending.cancelledCall?.join() - synchronized(lock) { + val restartResult = synchronized(lock) { if (cleanupJob !== pending.cleanup || cancelledCallJob !== pending.cancelledCall) { - return@synchronized + return@synchronized null } if (destroyed) { - restart() + val result = restart() + if (result !is Success) return@synchronized result destroyed = false cleanupJob = null cancelledCallJob = null cleanedUp = false } - return + Success(Unit) } + if (restartResult != null) return restartResult } } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt index ce3dec0ef86..52253e208b6 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -75,6 +75,7 @@ internal class CallJoinCoordinator( ) { private companion object { const val JOIN_FLIGHT_KEY = "join" + const val CALL_RESTART_FAILED = "CALL_RESTART_FAILED" } private val logger by taggedLogger("Call:JoinCoordinator:$type:$id") @@ -137,7 +138,17 @@ internal class CallJoinCoordinator( callJoinInterceptor: CallJoinInterceptor? = null, ): Result { callAnalytics.joinAnalytics.onJoinFunctionStart() - lifecycle.prepareForJoin() + when (val preparation = lifecycle.prepareForJoin()) { + is Failure -> { + state._connection.value = RealtimeConnection.Failed(preparation.value) + callAnalytics.joinAnalytics.onJoinPreparationError( + failureCode = CALL_RESTART_FAILED, + message = preparation.value.message, + ) + return preparation + } + is Success -> Unit + } var coalesced = false return joinFlight.run( JOIN_FLIGHT_KEY, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt index 527463174bd..5debc62bd21 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -71,11 +71,10 @@ internal class CallLifecycleManager( private var sfuSocketReconnectionTime: Long? = null /** Wait outside the call scope, which may already be cancelled by leave. */ - suspend fun prepareForJoin() { + suspend fun prepareForJoin(): Result = cleanupState.prepareForJoin { restartCoordinator.restart() } - } fun leave(reason: CallLeaveReason) { logger.d { "[leave] #ringing; call_cid:$cid" } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt index f519058c009..af4c5d78c22 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -22,6 +22,8 @@ import io.getstream.android.video.api.generated.models.CallSettingsResponse import io.getstream.android.video.api.generated.models.OwnCapability import io.getstream.android.video.api.generated.models.VideoSettingsResponse import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.result.Result.Success import io.getstream.video.android.core.CallState import io.getstream.video.android.core.CameraDirection import io.getstream.video.android.core.DeviceStatus @@ -425,9 +427,10 @@ internal class CallMediaManager( mediaManager.microphone.disable() } - override fun restart() { + override fun restart(): Result { startAudioLevelMonitoring() if (mediaManagerDelegate.isInitialized()) mediaManager.prepareForJoin() + return Success(Unit) } fun cleanup() { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinator.kt index ebbd08073fa..882a0f8b88e 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinator.kt @@ -16,6 +16,9 @@ package io.getstream.video.android.core.call.components +import io.getstream.result.Result +import io.getstream.result.Result.Failure +import io.getstream.result.Result.Success import io.getstream.video.android.core.call.Restartable /** Coordinates restoration of a Call after its previous lifecycle generation has ended. */ @@ -25,9 +28,15 @@ internal class CallRestartCoordinator( private val onRestarted: () -> Unit = {}, ) { /** Restarts generation-owned resources first, then each persistent component in order. */ - fun restart() { - scopeProvider.restart() - restartables.forEach(Restartable::restart) + fun restart(): Result { + val scopeResult = scopeProvider.restart() + if (scopeResult is Failure) return scopeResult + + restartables.forEach { restartable -> + val result = restartable.restart() + if (result is Failure) return result + } onRestarted() + return Success(Unit) } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt index c3516f59333..321898608a2 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt @@ -16,6 +16,8 @@ package io.getstream.video.android.core.call.components +import io.getstream.result.Result +import io.getstream.result.Result.Success import io.getstream.video.android.core.call.Restartable import io.getstream.video.android.core.call.RtcSession import kotlinx.coroutines.flow.MutableStateFlow @@ -52,7 +54,7 @@ internal class CallSessionManager() : Restartable { var unifiedSessionId = UUID.randomUUID().toString() private set - override fun restart() { + override fun restart(): Result { sessionId = UUID.randomUUID().toString() unifiedSessionId = UUID.randomUUID().toString() nonFastReconnectAttempts = 0 @@ -60,6 +62,7 @@ internal class CallSessionManager() : Restartable { reconnectStartTime = 0L location = null clearFailedSfuIds() + return Success(Unit) } /** Cached SFU location used when (re)joining a call. */ diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt index 54a19fb71ac..faa92f889ec 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt @@ -16,6 +16,10 @@ package io.getstream.video.android.core.call.scope +import io.getstream.result.Error +import io.getstream.result.Result +import io.getstream.result.Result.Failure +import io.getstream.result.Result.Success import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -95,11 +99,16 @@ internal class CallScopeGenerationManager( } } - internal fun restart(): Int = synchronized(lifecycleLock) { - check(current.get().isCleanedUp) { - "ScopeProvider cannot restart before its current generation is cleaned up" + internal fun restart(): Result = synchronized(lifecycleLock) { + if (!current.get().isCleanedUp) { + return@synchronized Failure( + Error.GenericError( + "ScopeProvider cannot restart before its current generation is cleaned up", + ), + ) } - createGeneration().also(current::set).debugInstanceId + current.set(createGeneration()) + Success(Unit) } private fun createGeneration(): GenerationState { 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 b708f95e959..0d68110a876 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 @@ -17,6 +17,8 @@ package io.getstream.video.android.core.call.scope import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.result.Result.Success import kotlinx.coroutines.CompletableJob import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -87,10 +89,13 @@ internal class ScopeProviderImpl( return cleanupResult.callJob } - override fun restart() { - val debugInstanceId = generationManager.restart() - logger.d { "${debugInstanceId.logPrefix()} Restarted ScopeProvider" } - restartListeners.forEach { it() } + override fun restart(): Result = generationManager.restart().also { result -> + if (result is Success) { + logger.d { + "${generationManager.currentDebugInstanceId.logPrefix()} Restarted ScopeProvider" + } + restartListeners.forEach { it() } + } } internal fun onRestart(listener: () -> Unit) { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsState.kt index 103616afbda..8780174426d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/sorting/SortedParticipantsState.kt @@ -17,6 +17,8 @@ package io.getstream.video.android.core.sorting import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.result.Result.Success import io.getstream.video.android.core.Call import io.getstream.video.android.core.ParticipantState import io.getstream.video.android.core.call.Restartable @@ -71,7 +73,7 @@ internal class SortedParticipantsState( restart() } - override fun restart() { + override fun restart(): Result { inputsJob?.cancel() eventsJob?.cancel() lastSortOrder = emptyList() @@ -92,6 +94,7 @@ internal class SortedParticipantsState( resort(currentSortInputs()) } } + return Success(Unit) } fun setPreset(preset: SortPreset) { diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/CallAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/CallAnalyticsTest.kt index a16a14d1478..0767b7dc975 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/CallAnalyticsTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/CallAnalyticsTest.kt @@ -202,4 +202,19 @@ class CallAnalyticsTest { assertEquals("", analytics.videoAnalytics.stageId.value) } + + @Test + fun `restart preserves the join attempt started before lifecycle preparation`() = runTest { + val analytics = callAnalytics(backgroundScope) + analytics.joinAnalyticsStateHolder.updateJoinStageAttemptId("current-attempt") + analytics.joinAnalyticsStateHolder.updateStage(Stage.COMPLETED) + + analytics.restart() + + assertEquals( + "current-attempt", + analytics.joinAnalyticsStateHolder.state.value.joinStageAttemptId, + ) + assertEquals(Stage.NOT_STARTED, analytics.joinAnalyticsStateHolder.state.value.joinStage) + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt index aaabe5c5bc7..74ae6ecd2f1 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt @@ -172,6 +172,28 @@ class JoinAnalyticsTest { assertEquals(0, joinSuccessCount) } + @Test + fun `onJoinPreparationError reports the failure message`() { + joinAnalytics.onJoinFunctionStart() + + joinAnalytics.onJoinPreparationError( + failureCode = "CALL_RESTART_FAILED", + message = "scope restart failed", + ) + + verify(exactly = 1) { + reporter.reportCoordinatorJoinCompleted( + "stage-1", + false, + 0, + "scope restart failed", + "CALL_RESTART_FAILED", + null, + ) + } + assertEquals(Stage.COMPLETED, stateHolder.state.value.joinStage) + } + @Test fun `onJoinRequestRetryExhausted behaves like a permanent error`() { joinAnalytics.onJoinRequestStart(JoinReason.FirstAttempt) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallCleanupStateTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallCleanupStateTest.kt new file mode 100644 index 00000000000..db5fbe154c7 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallCleanupStateTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core.call.components + +import io.getstream.result.Error +import io.getstream.result.Result.Failure +import io.getstream.result.Result.Success +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CallCleanupStateTest { + + @Test + fun `failed restart keeps the call destroyed so a later join can retry`() = runTest { + val cleanupState = CallCleanupState() + cleanupState.startLeave { + Job().apply { complete() } + } + val failure = Failure(Error.GenericError("scope restart failed")) + + assertEquals(failure, cleanupState.prepareForJoin { failure }) + assertTrue(cleanupState.isDestroyed) + + assertEquals(Success(Unit), cleanupState.prepareForJoin { Success(Unit) }) + assertFalse(cleanupState.isDestroyed) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt index d665fac6e60..5d559cdc9a5 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -118,6 +118,7 @@ class CallJoinCoordinatorTest { every { state.connection } returns connectionFlow every { state.settings } returns MutableStateFlow(null) every { state.e2eeEnabled } returns MutableStateFlow(false) + coEvery { lifecycle.prepareForJoin() } returns Success(Unit) coEvery { clientImpl.getCachedLocation() } returns Success("test-location") } @@ -228,6 +229,29 @@ class CallJoinCoordinatorTest { verify { sessionMonitor.monitorSession(mockJoinResponse) } } + @Test + fun `join returns lifecycle preparation failure and reports its message`() = runTest( + testDispatcher, + ) { + val error = Error.GenericError("scope restart failed") + coEvery { lifecycle.prepareForJoin() } returns Failure(error) + + val result = coordinator().join() + + assertThat(result).isEqualTo(Failure(error)) + assertThat(connectionFlow.value).isEqualTo(RealtimeConnection.Failed(error)) + verify(exactly = 1) { callAnalytics.joinAnalytics.onJoinFunctionStart() } + verify(exactly = 1) { + callAnalytics.joinAnalytics.onJoinPreparationError( + failureCode = "CALL_RESTART_FAILED", + message = "scope restart failed", + ) + } + coVerify(exactly = 0) { + apiClient.joinRequest(any(), any(), any(), any(), any(), any(), any(), any(), any()) + } + } + @Test fun `first join passes e2ee from call state`() = runTest(testDispatcher) { every { state.e2eeEnabled } returns MutableStateFlow(true) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinatorTest.kt index 3798c2df51c..8d40923d3dc 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinatorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallRestartCoordinatorTest.kt @@ -16,9 +16,13 @@ package io.getstream.video.android.core.call.components +import io.getstream.result.Error +import io.getstream.result.Result.Failure +import io.getstream.result.Result.Success import io.getstream.video.android.core.call.Restartable import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertSame class CallRestartCoordinatorTest { @@ -26,16 +30,46 @@ class CallRestartCoordinatorTest { fun `restart recreates scope before restarting components and completing bookkeeping`() { val events = mutableListOf() val coordinator = CallRestartCoordinator( - scopeProvider = Restartable { events += "scope" }, + scopeProvider = Restartable { + events += "scope" + Success(Unit) + }, restartables = listOf( - Restartable { events += "state" }, - Restartable { events += "media" }, + Restartable { + events += "state" + Success(Unit) + }, + Restartable { + events += "media" + Success(Unit) + }, ), onRestarted = { events += "bookkeeping" }, ) - coordinator.restart() + assertEquals(Success(Unit), coordinator.restart()) assertEquals(listOf("scope", "state", "media", "bookkeeping"), events) } + + @Test + fun `restart returns scope failure without restarting persistent components`() { + val failure = Failure(Error.GenericError("scope restart failed")) + val events = mutableListOf() + val coordinator = CallRestartCoordinator( + scopeProvider = Restartable { failure }, + restartables = listOf( + Restartable { + events += "state" + Success(Unit) + }, + ), + onRestarted = { events += "bookkeeping" }, + ) + + val result = coordinator.restart() + + assertSame(failure, result) + assertEquals(emptyList(), events) + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt index 2216da23a45..2ad5aa481fe 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderTest.kt @@ -16,6 +16,8 @@ package io.getstream.video.android.core.call.scope +import io.getstream.result.Result.Failure +import io.getstream.result.Result.Success import io.getstream.video.android.core.socket.common.scope.ClientScope import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Job @@ -27,6 +29,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -137,7 +140,7 @@ class ScopeProviderTest { scopeProvider.cleanup()?.join() assertTrue(firstCallJob.isCancelled) - scopeProvider.restart() + assertEquals(Success(Unit), scopeProvider.restart()) val secondCallScope = scopeProvider.callScope val secondCallJob = secondCallScope.coroutineContext[Job]!! @@ -147,4 +150,19 @@ class ScopeProviderTest { assertTrue(secondCallJob.isActive) assertNotNull(scopeProvider.getRtcSessionScope(SupervisorJob(), "test-call-1")) } + + @Test + fun `restart before cleanup returns a failure and keeps the current scope`() = runTest { + val firstCallScope = scopeProvider.callScope + + val result = scopeProvider.restart() + + assertTrue(result is Failure) + assertEquals( + "ScopeProvider cannot restart before its current generation is cleaned up", + (result as Failure).value.message, + ) + assertSame(firstCallScope, scopeProvider.callScope) + assertEquals(1, scopeProvider.debugInstanceId) + } } From 05cc7c6d07f695ef1075d633757bc53d535d94f6 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Mon, 26 Oct 2026 14:33:53 +0530 Subject: [PATCH 07/20] =?UTF-8?q?The=20same=20Call=20instance=20can=20be?= =?UTF-8?q?=20joined=20again=20after=20leave().=20leave()=20resets=20per-s?= =?UTF-8?q?ession=20state=20(session,=20tracks,=20participants,=20stats,?= =?UTF-8?q?=20reconnect=20counters,=20ringing=20state)=20but=20keeps=20the?= =?UTF-8?q?=20call=20identity=20and=20its=20events=20flow.=20client.call(t?= =?UTF-8?q?ype,=20id)=20keeps=20returning=20the=20same=20instance=20while?= =?UTF-8?q?=20it's=20alive.=20Tests:=20join=20=E2=86=92=20leave=20?= =?UTF-8?q?=E2=86=92=20join,=20with=20media=20and=20participant=20state=20?= =?UTF-8?q?asserted=20on=20the=20second=20join.=20anything=20launched=20in?= =?UTF-8?q?=20the=20call=20scope=20must=20be=20restarted=20on=20the=20next?= =?UTF-8?q?=20join.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../io/getstream/video/android/core/Call.kt | 1 + .../getstream/video/android/core/CallState.kt | 1 + .../getstream/video/android/core/CallStats.kt | 20 + .../call/components/CallSessionManager.kt | 7 +- .../core/call/components/CallStatsReporter.kt | 12 +- .../video/android/core/CallTestSeams.kt | 11 + .../video/android/core/ReusableCallTest.kt | 359 ++++++++++++++++++ 7 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ReusableCallTest.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 b051a0b57cf..3fbacafb45f 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 @@ -386,6 +386,7 @@ public class Call( restartables = listOf( sessionManager, callAnalytics, + statsReporter, Restartable { state.restart() Result.Success(Unit) 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 dee1c2679d6..d36e660bb53 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 @@ -2078,6 +2078,7 @@ public class CallState internal constructor( } internal fun restart() { + stats.reset() internalParticipants.clear() _participants.value = emptyMap() _activeSpeakers.value = emptyList() 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 6b20f5e757f..001f5e7980c 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 @@ -95,6 +95,20 @@ public class PeerConnectionStats(scope: CoroutineScope) { internal var lastAudioBytes: Long? = null internal var lastAudioTimestampUs: Double? = null + internal fun reset() { + _latency.value = 0 + _resolution.value = "" + _qualityDropReason.value = "" + _jitterInMs.value = 0 + _bitrateKbps.value = 0F + _videoCodec.value = "" + _audioBitrateKbps.value = 0F + _audioCodec.value = "" + _audioTargetBitrateKbps.value = 0F + lastAudioBytes = null + lastAudioTimestampUs = null + } + /** * Derives the audio bitrate from the change in [bytes] since the previous poll. * @@ -138,6 +152,12 @@ public class CallStats(val call: Call, val callScope: CoroutineScope) { val local: StateFlow = _local + internal fun reset() { + publisher.reset() + subscriber.reset() + _local.value = null + } + fun updateFromRTCStats(stats: RtcStatsReport?, isPublisher: Boolean = true) { if (stats == null) return // also see https://github.com/GetStream/stream-video-js/blob/main/packages/client/src/stats/state-store-stats-reporter.ts diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt index 321898608a2..bb4c3f477a7 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallSessionManager.kt @@ -60,6 +60,7 @@ internal class CallSessionManager() : Restartable { nonFastReconnectAttempts = 0 connectStartTime = 0L reconnectStartTime = 0L + reconnectDeadlineMillis = DEFAULT_RECONNECT_DEADLINE_MILLIS location = null clearFailedSfuIds() return Success(Unit) @@ -110,5 +111,9 @@ internal class CallSessionManager() : Restartable { * reconnect loop and the connectivity monitor, so it lives with the other shared * reconnect bookkeeping. */ - var reconnectDeadlineMillis: Int = 10_000 + var reconnectDeadlineMillis: Int = DEFAULT_RECONNECT_DEADLINE_MILLIS + + private companion object { + const val DEFAULT_RECONNECT_DEADLINE_MILLIS = 10_000 + } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt index 37b383b3db6..e2ecf99adbe 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallStatsReporter.kt @@ -17,8 +17,11 @@ package io.getstream.video.android.core.call.components import io.getstream.log.taggedLogger +import io.getstream.result.Result +import io.getstream.result.Result.Success import io.getstream.video.android.core.CallState import io.getstream.video.android.core.CallStatsReport +import io.getstream.video.android.core.call.Restartable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -36,7 +39,7 @@ internal class CallStatsReporter( private val scope: CoroutineScope, private val sessionManager: CallSessionManager, private val state: CallState, -) { +) : Restartable { private val logger by taggedLogger("Call:StatsReporter:$type:$id") /** Contains stats events for observation. */ @@ -66,6 +69,13 @@ internal class CallStatsReporter( callStatsReportingJob?.cancel() } + override fun restart(): Result { + stop() + statsReport.value = null + statLatencyHistory.value = listOf(0, 0, 0) + return Success(Unit) + } + suspend fun collectStats(): CallStatsReport { val currentSession = sessionManager.session.value val publisherStats = runCatching { currentSession?.getPublisherStats() }.getOrNull() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt index cfaaae0e23a..b89c14122a2 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallTestSeams.kt @@ -75,6 +75,17 @@ internal fun Call.injectLocation(location: String?) { */ internal fun Call.nonFastReconnectAttempts(): Int = sessionManager().nonFastReconnectAttempts +/** Seeds per-session reconnect bookkeeping for reusable Call lifecycle tests. */ +internal fun Call.injectReconnectState(attempts: Int, deadlineMillis: Int) { + sessionManager().nonFastReconnectAttempts = attempts + sessionManager().reconnectDeadlineMillis = deadlineMillis +} + +/** Seeds a transient ringing state that must not survive leave and rejoin. */ +internal fun CallState.injectRingingState(state: RingingState) { + mutableStateField("_ringingState").value = state +} + /** * Reaches the [CallSessionManager] that owns the session and reconnect bookkeeping. * diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ReusableCallTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ReusableCallTest.kt new file mode 100644 index 00000000000..2f382184b09 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ReusableCallTest.kt @@ -0,0 +1,359 @@ +/* + * 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 android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.jakewharton.threetenabp.AndroidThreeTen +import io.getstream.android.core.api.StreamClient +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.video.api.generated.models.Credentials +import io.getstream.android.video.api.generated.models.JoinCallResponse +import io.getstream.android.video.api.generated.models.SFUResponse +import io.getstream.android.video.api.generated.models.StatsOptions +import io.getstream.android.video.api.generated.models.UserResponse +import io.getstream.android.video.api.generated.models.VideoEvent +import io.getstream.result.Result.Success +import io.getstream.video.android.core.analytics.AnalyticsModule +import io.getstream.video.android.core.analytics.reporting.ClientEventReporter +import io.getstream.video.android.core.base.DispatcherRule +import io.getstream.video.android.core.base.toResponse +import io.getstream.video.android.core.call.RtcSession +import io.getstream.video.android.core.call.SfuConnectionResult +import io.getstream.video.android.core.call.connection.Publisher +import io.getstream.video.android.core.call.connection.Subscriber +import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule +import io.getstream.video.android.core.internal.network.NetworkStateProvider +import io.getstream.video.android.core.model.AudioTrack +import io.getstream.video.android.core.model.VideoTrack +import io.getstream.video.android.core.notifications.internal.StreamNotificationManager +import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry +import io.getstream.video.android.core.permission.android.StreamPermissionCheck +import io.getstream.video.android.core.socket.common.token.TokenRepository +import io.getstream.video.android.core.sounds.RingingCallVibrationConfig +import io.getstream.video.android.core.sounds.Sounds +import io.getstream.video.android.model.User +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.threeten.bp.OffsetDateTime +import stream.video.sfu.models.Participant +import stream.video.sfu.models.TrackType + +@RunWith(RobolectricTestRunner::class) +class ReusableCallTest { + + @get:Rule + val dispatcherRule = DispatcherRule() + + @Before + fun setUp() { + AndroidThreeTen.init(ApplicationProvider.getApplicationContext()) + } + + @After + fun tearDown() { + Call.testInstanceProvider.mediaManagerCreator = null + unmockkAll() + } + + @Test + fun `same Call joins leaves and joins again with fresh session state`() = runTest { + val mediaManager = reusableMediaManager() + Call.testInstanceProvider.mediaManagerCreator = { mediaManager } + val client = reusableClient(backgroundScope) + val call = client.call("default", "reusable-call") + val events = call.events + + assertThat(client.call("default", "reusable-call")).isSameInstanceAs(call) + + val firstSession = reusableSession() + val secondSession = reusableSession() + val sessions = ArrayDeque(listOf(firstSession, secondSession)) + var secondGenerationSnapshot: ResetSnapshot? = null + val firstSessionId = call.sessionId + call.unitTestRtcSessionFactory = { + if (sessions.size == 1) { + secondGenerationSnapshot = ResetSnapshot( + sessionWasCleared = call.session.value == null, + participantsWereCleared = call.state.participants.value.isEmpty(), + statsReportWasCleared = call.statsReport.value == null, + statsHistory = call.statLatencyHistory.value, + publisherLatency = call.state.stats.publisher.latency.value, + reconnectAttempts = call.nonFastReconnectAttempts(), + reconnectDeadlineMillis = call.reconnectDeadlineMillis, + ringingState = call.state.ringingState.value, + sessionId = call.sessionId, + ) + } + sessions.removeFirst() + } + var joinNumber = 0 + coEvery { client.getCachedLocation() } returns Success("test-location") + coEvery { + client.joinCall( + any(), any(), any(), any(), any(), any(), any(), any(), + any(), any(), any(), any(), any(), any(), any(), + ) + } coAnswers { + joinNumber++ + Success(joinResponse(call, "session-$joinNumber")) + } + coEvery { firstSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success + + val secondVideoTrack = VideoTrack( + streamId = "second-video", + video = mockk(relaxed = true), + ) + val secondAudioTrack = AudioTrack( + streamId = "second-audio", + audio = mockk(relaxed = true), + ) + coEvery { secondSession.connectInternal(any(), any()) } coAnswers { + val participant = call.state.getOrCreateParticipant( + Participant( + user_id = "second-user", + session_id = "second-participant", + published_tracks = listOf( + TrackType.TRACK_TYPE_VIDEO, + TrackType.TRACK_TYPE_AUDIO, + ), + track_lookup_prefix = "second", + name = "Second participant", + image = "", + ), + ) + participant.setVideoTrack(secondVideoTrack) + participant._audioTrack.value = secondAudioTrack + call.state.replaceParticipants(listOf(participant)) + SfuConnectionResult.Success + } + + val participantEmissions = mutableListOf>() + val participantCollector = backgroundScope.launch { + call.state.participants.collect { participants -> + participantEmissions += participants.map(ParticipantState::sessionId) + } + } + val receivedEvents = mutableListOf() + val eventCollector = backgroundScope.launch { events.collect(receivedEvents::add) } + runCurrent() + + assertThat(call.join()).isEqualTo(Success(firstSession)) + + val oldVideoTrack = VideoTrack( + streamId = "old-video", + video = mockk(relaxed = true), + ) + val oldAudioTrack = AudioTrack( + streamId = "old-audio", + audio = mockk(relaxed = true), + ) + val oldParticipant = call.state.getOrCreateParticipant( + Participant( + user_id = "old-user", + session_id = "old-participant", + published_tracks = listOf( + TrackType.TRACK_TYPE_VIDEO, + TrackType.TRACK_TYPE_AUDIO, + ), + track_lookup_prefix = "old", + name = "Old participant", + image = "", + ), + ) + oldParticipant.setVideoTrack(oldVideoTrack) + oldParticipant._audioTrack.value = oldAudioTrack + call.state.replaceParticipants(listOf(oldParticipant)) + call.statsReport.value = mockk(relaxed = true) + call.statLatencyHistory.value = listOf(17, 23) + call.state.stats.publisher._latency.value = 91 + call.injectReconnectState(attempts = 4, deadlineMillis = 42_000) + call.state.injectRingingState(RingingState.Active) + + call.leave() + assertThat(call.join()).isEqualTo(Success(secondSession)) + runCurrent() + + val snapshot = checkNotNull(secondGenerationSnapshot) + assertThat(snapshot.sessionWasCleared).isTrue() + assertThat(snapshot.participantsWereCleared).isTrue() + assertThat(snapshot.statsReportWasCleared).isTrue() + assertThat(snapshot.statsHistory).containsExactly(0, 0, 0).inOrder() + assertThat(snapshot.publisherLatency).isEqualTo(0) + assertThat(snapshot.reconnectAttempts).isEqualTo(0) + assertThat(snapshot.reconnectDeadlineMillis).isEqualTo(10_000) + assertThat(snapshot.ringingState).isEqualTo(RingingState.Idle) + assertThat(snapshot.sessionId).isNotEqualTo(firstSessionId) + + assertThat(call.session.value).isSameInstanceAs(secondSession) + assertThat(call.state.getParticipantBySessionId("old-participant")).isNull() + val secondParticipant = call.state.getParticipantBySessionId("second-participant") + assertThat(secondParticipant).isNotNull() + assertThat(secondParticipant!!.videoTrack.value).isSameInstanceAs(secondVideoTrack) + assertThat(secondParticipant.audioTrack.value).isSameInstanceAs(secondAudioTrack) + assertThat(secondParticipant.videoEnabled.value).isTrue() + assertThat(secondParticipant.audioEnabled.value).isTrue() + assertThat(participantCollector.isActive).isTrue() + assertThat(participantEmissions).contains(listOf("second-participant")) + + assertThat(call.events).isSameInstanceAs(events) + val event = mockk(relaxed = true) + call.fireEvent(event) + runCurrent() + assertThat(receivedEvents).contains(event) + assertThat(eventCollector.isActive).isTrue() + assertThat(client.call("default", "reusable-call")).isSameInstanceAs(call) + + verify(atLeast = 1) { mediaManager.cleanup() } + verify(atLeast = 1) { mediaManager.prepareForJoin() } + + call.leave() + runCurrent() + } + + private data class ResetSnapshot( + val sessionWasCleared: Boolean, + val participantsWereCleared: Boolean, + val statsReportWasCleared: Boolean, + val statsHistory: List, + val publisherLatency: Int, + val reconnectAttempts: Int, + val reconnectDeadlineMillis: Int, + val ringingState: RingingState, + val sessionId: String, + ) + + private fun reusableClient(scope: CoroutineScope): StreamVideoClient { + val streamClient = mockk(relaxed = true) + every { streamClient.subscribe(any()) } returns Result.success( + mockk(relaxed = true), + ) + every { streamClient.connectionState } returns + MutableStateFlow(StreamConnectionState.Idle) + + val network = mockk(relaxed = true) { + every { isConnected() } returns true + } + val coordinator = mockk(relaxed = true) { + every { networkStateProvider } returns network + } + val analytics = mockk(relaxed = true) { + every { clientEventReporter } returns mockk(relaxed = true) + } + val permissions = mockk { + every { checkAndroidPermissionsGroup(any(), any()) } returns (true to emptySet()) + } + val callConfigs = CallServiceConfigRegistry().apply { + register("default") { setRunCallServiceInForeground(false) } + } + + return spyk( + StreamVideoClient( + context = ApplicationProvider.getApplicationContext(), + scope = scope, + initialUser = User(id = "test-user", role = "user"), + apiKey = "test-api-key", + token = "test-token", + lifecycle = mockk(relaxed = true), + coordinatorConnectionModule = coordinator, + streamClient = streamClient, + tokenRepository = mockk(relaxed = true), + streamNotificationManager = mockk(relaxed = true), + enableCallNotificationUpdates = false, + callServiceConfigRegistry = callConfigs, + sounds = mockk(relaxed = true), + vibrationConfig = mockk(relaxed = true), + permissionCheck = permissions, + analytics = analytics, + ), + ) + } + + private fun reusableMediaManager(): MediaManagerImpl { + val camera = mockk(relaxed = true) { + every { status } returns MutableStateFlow(DeviceStatus.NotSelected) + } + val microphone = mockk(relaxed = true) { + every { status } returns MutableStateFlow(DeviceStatus.NotSelected) + every { isEnabled } returns MutableStateFlow(false) + every { devices } returns MutableStateFlow(emptyList()) + } + val speaker = mockk(relaxed = true) { + every { status } returns MutableStateFlow(DeviceStatus.NotSelected) + } + return mockk(relaxed = true) { + every { this@mockk.camera } returns camera + every { this@mockk.microphone } returns microphone + every { this@mockk.speaker } returns speaker + every { screenShare } returns mockk(relaxed = true) + } + } + + private fun reusableSession(): RtcSession = mockk(relaxed = true) { + every { publisher } returns MutableStateFlow(null) + every { subscriber } returns MutableStateFlow(null) + every { socket.events() } returns MutableSharedFlow() + } + + private fun joinResponse(call: Call, sessionId: String): JoinCallResponse { + val now = OffsetDateTime.parse("2026-01-01T00:00:00Z") + val createdBy = UserResponse( + createdAt = now, + id = "test-user", + language = "en", + role = "user", + updatedAt = now, + ) + return JoinCallResponse( + created = false, + duration = "0", + call = call.toResponse(createdBy).copy(currentSessionId = sessionId), + credentials = Credentials( + token = "sfu-token", + server = SFUResponse( + edgeName = "test-edge", + url = "https://test-sfu", + wsEndpoint = "wss://test-sfu", + ), + ), + statsOptions = StatsOptions( + enableRtcStats = false, + reportingIntervalMs = 60_000, + ), + ) + } +} From ae1e5b32869e4a7bdf5e17468cef72dc26c3de9e Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Mon, 26 Oct 2026 16:40:36 +0530 Subject: [PATCH 08/20] fix(core): preserve call cache ownership across reusable joins Use atomic call-cache operations so a retained Call can rejoin without replacing a newer instance created for the same CID. Route coordinator and SFU events to successfully rejoined uncached calls, and remove those event targets on leave. Add coverage for concurrent call creation, identity-aware cleanup, conditional cache restoration, and event delivery after leave and rejoin. --- .../io/getstream/video/android/core/Call.kt | 1 + .../video/android/core/StreamVideoClient.kt | 69 +++++++++++++++---- .../video/android/core/ReusableCallTest.kt | 27 +++++++- .../android/core/StreamVideoClientTest.kt | 53 +++++++++++++- 4 files changed, 134 insertions(+), 16 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 3fbacafb45f..d465cb6678d 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 @@ -219,6 +219,7 @@ public class Call( } override fun markActive() { + clientImpl.onCallJoined(this@Call) client.state.setActiveCall(this@Call) } 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 179018b14da..bac99578f34 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 @@ -169,6 +169,7 @@ import okhttp3.Request import okhttp3.Response import retrofit2.HttpException import java.util.* +import java.util.concurrent.ConcurrentHashMap internal const val WAIT_FOR_CONNECTION_ID_TIMEOUT = 5000L internal const val defaultAudioUsage = AudioAttributes.USAGE_VOICE_COMMUNICATION @@ -244,7 +245,33 @@ internal class StreamVideoClient internal constructor( private val logger by taggedLogger("Call:StreamVideo") private var subscriptions = mutableSetOf() - private var calls = mutableMapOf() + + /** + * Canonical get-or-create cache used by [call]. + * + * The following APIs can access the cache concurrently: + * - `client.call(type, id)` → get or create a call. + * - `call.join()` → restore a retained call only when its CID is absent. + * - `call.leave()` → remove only the matching call instance. + * + * [ConcurrentHashMap] coordinates these operations without locking the complete call lifecycle. + */ + private val calls = ConcurrentHashMap() + + /** + * Rejoined [Call] instances that are not stored in [calls], but still need coordinator and SFU + * events. + * + * A non-cached call can exist when an app retains `callA`, invokes `callA.leave()` (which + * removes it from [calls]), and then obtains `callA1` through [call] for the same CID. If the + * app later invokes `callA.join()`, the retained call is allowed to rejoin, but it must not + * replace the newer `callA1` in the get-or-create cache. + * + * Coordinator and SFU events are routed through `calls[cid]`. Because the successfully rejoined + * `callA` is no longer that cached instance, it is added here so both event sources are also + * propagated to it. The call is removed from this map when it leaves again. + */ + private val reusedCallEventTargets = ConcurrentHashMap>() private val destroyedCalls = LruCache(100) internal val callSoundAndVibrationPlayer = CallSoundAndVibrationPlayer(context) @@ -278,17 +305,33 @@ 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, call) + reusedCallEventTargets.computeIfPresent(call.cid) { _, reusedCalls -> + reusedCalls.remove(call) + reusedCalls.takeUnless { it.isEmpty() } + } } internal fun onCallReused(call: Call) { destroyedCalls.remove(call.hashCode()) - calls[call.cid] = call + calls.putIfAbsent(call.cid, call) + } + + internal fun onCallJoined(call: Call) { + val cachedCall = calls.putIfAbsent(call.cid, call) + if (cachedCall == null || cachedCall === call) { + return + } + + reusedCallEventTargets.compute(call.cid) { _, reusedCalls -> + (reusedCalls ?: ConcurrentHashMap.newKeySet()).apply { add(call) } + } } override fun cleanup() { // remove all cached calls calls.clear() + reusedCallEventTargets.clear() destroyedCalls.evictAll() // cancel the StreamClient subscription before tearing down the socket streamClientSubscription?.cancel() @@ -703,7 +746,7 @@ internal class StreamVideoClient internal constructor( } internal fun forwardToCallSubscriptions(cid: String, event: VideoEvent) { - calls[cid]?.fireEvent(event) + forEachCallInstance(cid) { call -> call.fireEvent(event) } notifyDestroyedCalls(event) } @@ -723,13 +766,21 @@ internal class StreamVideoClient internal constructor( } internal fun propagateEventToCall(cid: String, event: VideoEvent) { - calls[cid]?.let { call -> + forEachCallInstance(cid) { call -> call.state.handleEvent(event) call.session.value?.handleEvent(event) call.handleEvent(event) } } + private inline fun forEachCallInstance(cid: String, action: (Call) -> Unit) { + val cachedCall = calls[cid] + cachedCall?.let(action) + reusedCallEventTargets[cid]?.forEach { reusedCall -> + if (reusedCall !== cachedCall) action(reusedCall) + } + } + internal fun deliverToDestroyedCalls(event: VideoEvent) { deliverIntentToDestroyedCalls(event) } @@ -1288,13 +1339,7 @@ internal class StreamVideoClient internal constructor( val idOrRandom = id.ifEmpty { UUID.randomUUID().toString() } val cid = "$type:$idOrRandom" - return if (calls.contains(cid)) { - calls[cid]!! - } else { - val call = Call(this, type, idOrRandom, user) - calls[cid] = call - call - } + return calls.computeIfAbsent(cid) { Call(this, type, idOrRandom, user) } } @OptIn(InternalCoroutinesApi::class) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ReusableCallTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ReusableCallTest.kt index 2f382184b09..1d553dd373b 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ReusableCallTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ReusableCallTest.kt @@ -39,6 +39,7 @@ import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.call.SfuConnectionResult import io.getstream.video.android.core.call.connection.Publisher import io.getstream.video.android.core.call.connection.Subscriber +import io.getstream.video.android.core.events.ParticipantMigrationCompleteEvent import io.getstream.video.android.core.internal.module.CoordinatorConnectionModule import io.getstream.video.android.core.internal.network.NetworkStateProvider import io.getstream.video.android.core.model.AudioTrack @@ -102,11 +103,12 @@ class ReusableCallTest { val firstSession = reusableSession() val secondSession = reusableSession() - val sessions = ArrayDeque(listOf(firstSession, secondSession)) + val thirdSession = reusableSession() + val sessions = ArrayDeque(listOf(firstSession, secondSession, thirdSession)) var secondGenerationSnapshot: ResetSnapshot? = null val firstSessionId = call.sessionId call.unitTestRtcSessionFactory = { - if (sessions.size == 1) { + if (sessions.size == 2) { secondGenerationSnapshot = ResetSnapshot( sessionWasCleared = call.session.value == null, participantsWereCleared = call.state.participants.value.isEmpty(), @@ -133,6 +135,7 @@ class ReusableCallTest { Success(joinResponse(call, "session-$joinNumber")) } coEvery { firstSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success + coEvery { thirdSession.connectInternal(any(), any()) } returns SfuConnectionResult.Success val secondVideoTrack = VideoTrack( streamId = "second-video", @@ -243,6 +246,26 @@ class ReusableCallTest { call.leave() runCurrent() + + val replacementCall = client.call("default", "reusable-call") + assertThat(replacementCall).isNotSameInstanceAs(call) + + assertThat(call.join()).isEqualTo(Success(thirdSession)) + assertThat(client.call("default", "reusable-call")).isSameInstanceAs(replacementCall) + + val routedEvent = mockk(relaxed = true) + client.fireEvent(routedEvent, call.cid) + runCurrent() + assertThat(receivedEvents).contains(routedEvent) + + val sfuEvent = ParticipantMigrationCompleteEvent + client.fireEvent(sfuEvent, call.cid) + runCurrent() + assertThat(receivedEvents).contains(sfuEvent) + + call.leave() + runCurrent() + assertThat(client.call("default", "reusable-call")).isSameInstanceAs(replacementCall) } private data class ResetSnapshot( diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt index 01a3898d48e..7e9917f3463 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoClientTest.kt @@ -60,9 +60,14 @@ import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import org.threeten.bp.OffsetDateTime +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame import kotlin.test.assertTrue class StreamVideoClientTest { @@ -132,6 +137,48 @@ class StreamVideoClientTest { return ClientHarness(client, streamClientMock, tokenRepo) } + @Test + fun `concurrent call requests return the same cached instance`() { + val requestCount = 16 + val startRequests = CountDownLatch(1) + val executor = Executors.newFixedThreadPool(requestCount) + + try { + val requests = List(requestCount) { + executor.submit { + startRequests.await() + client.call("default", "concurrent-call") + } + } + + startRequests.countDown() + val calls = requests.map { it.get(10, TimeUnit.SECONDS) } + + assertTrue(calls.all { it === calls.first() }) + } finally { + executor.shutdownNow() + } + } + + @Test + fun `reused call fills only an empty cache and old cleanup preserves its replacement`() { + val originalCall = client.call("default", "reusable-call") + + client.onCallCleanUp(originalCall) + client.onCallReused(originalCall) + assertSame(originalCall, client.call("default", "reusable-call")) + + client.onCallCleanUp(originalCall) + val replacementCall = client.call("default", "reusable-call") + assertNotSame(originalCall, replacementCall) + + client.onCallReused(originalCall) + assertSame(replacementCall, client.call("default", "reusable-call")) + + client.onCallCleanUp(originalCall) + assertSame(replacementCall, client.call("default", "reusable-call")) + } + @Test fun `resolveSelectedCid returns explicit cid when provided`() { val event = mockk() @@ -236,10 +283,12 @@ class StreamVideoClientTest { every { session } returns sessionFlow } - client::class.java.getDeclaredField("calls").apply { + @Suppress("UNCHECKED_CAST") + val calls = client::class.java.getDeclaredField("calls").run { isAccessible = true - set(client, mutableMapOf("video:123" to call)) + get(client) as MutableMap } + calls["video:123"] = call client.propagateEventToCall("video:123", event) From f84fc5125721efc8ace9a2cc9b36a64a86595339 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 13:19:45 +0530 Subject: [PATCH 09/20] Improve call cleaning up --- .../io/getstream/video/android/core/Call.kt | 21 ++- .../video/android/core/StreamVideo.kt | 7 +- .../core/call/components/CallCleanupState.kt | 164 +++++++++++++++--- .../android/core/MicrophoneManagerTest.kt | 2 +- .../call/components/CallCleanupStateTest.kt | 33 ++++ 5 files changed, 195 insertions(+), 32 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 d465cb6678d..e52a90d7419 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 @@ -632,7 +632,10 @@ public class Call( /** * Joins the call. Concurrent callers share one in-flight attempt. - * The same instance can be joined again after [leave]; pending cleanup finishes first. + * + * When invoked after [leave], this method waits for pending cleanup, resets session-specific + * state, and creates a new coroutine scope and [RtcSession]. The [Call] instance and its + * [events] flow remain stable across the leave and join cycle. * * Cancelling this coroutine does not abort that attempt — only [leave] does. * @@ -769,12 +772,28 @@ public class Call( // endregion + /** + * Leaves the call for the supplied SDK reason and starts cleanup asynchronously. + * + * The call is removed from the client's get-or-create cache and its current coroutine scope and + * RTC session are cleaned up. The same [Call] instance can be retained and joined again. A + * later [join] waits for cleanup to finish before creating a new scope and RTC session. + */ @InternalStreamVideoApi fun leave(reason: CallLeaveReason) { detachE2EEManager() lifecycle.leave(reason) } + /** + * Leaves the call and starts cleanup asynchronously. + * + * The call is removed from the client's get-or-create cache and its current coroutine scope and + * RTC session are cleaned up. The same [Call] instance can be retained and joined again. A + * later [join] waits for cleanup to finish before creating a new scope and RTC session. + * + * @param reason A user-defined reason included with the leave operation. + */ fun leave(reason: String = "user") { detachE2EEManager() lifecycle.leave(reason) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt index 9fe05737fda..a2147dfefe0 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideo.kt @@ -53,8 +53,11 @@ public interface StreamVideo : NotificationHandler { val state: ClientState /** - * Create a call with the given type and id. - * For GetStream Devs: Careful when invoking it during call/service cleanup as it will fill itself in [io.getstream.video.android.core.StreamVideoClient.calls] map + * Returns the cached [Call] for the given type and ID, or creates one when none is cached. + * + * A call is removed from the cache when [Call.leave] is invoked. Calling this method afterward + * may therefore return a new [Call] instance. A previously retained instance can still be + * joined again independently. */ public fun call(type: String, id: String = ""): Call diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt index 59bb3313664..49485b5560a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt @@ -16,43 +16,115 @@ package io.getstream.video.android.core.call.components +import io.getstream.result.Error import io.getstream.result.Result +import io.getstream.result.Result.Failure import io.getstream.result.Result.Success import kotlinx.coroutines.Job +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.atomic.AtomicBoolean -/** Stores and synchronizes the cleanup state shared by leave, cleanup and the next join. */ +/** + * Synchronizes the lifecycle boundary required to reuse a + * [io.getstream.video.android.core.Call] after it has been left. + * + * `Call.leave()` returns while cleanup continues asynchronously, and the same instance may receive + * another `Call.join()` immediately afterward. The cancelled call scope cannot be reused, and the + * new scope and RTC session must not start until the old cleanup job and call job have both finished. + * + * This class coordinates exactly these lifecycle operations: + * - `Call.leave()` through [startLeave], making repeated leave requests idempotent and retaining the + * asynchronous cleanup job. + * - [CallLifecycleManager.cleanup] through [cleanupOnce] and [recordCancelledCallJob], ensuring + * terminal cleanup runs once and exposing the cancelled call job that still needs to finish. + * - `Call.join()` through [prepareForJoin], waiting for both jobs before restarting the call scope + * and session-specific state. + * + * Cache access through `StreamVideo.call(type, id)` is coordinated separately by + * `StreamVideoClient`; this class only owns synchronization between leave, cleanup, and rejoin for + * one retained `Call` instance. + */ internal class CallCleanupState { + private companion object { + private const val CLEANUP_TIMEOUT_MS = 30_000L + } + + /** + * The cleanup jobs that this join preparation actually waited for. + * + * [prepareForJoin] reads and waits for [cleanup] first. That job records [cancelledCall] while + * releasing the previous call scope, so the cancelled job is read only after cleanup finishes. + * The pair is then compared with the stored jobs to detect a genuinely newer leave, cleanup, or + * concurrent restart. + * + * @property cleanup the asynchronous leave and cleanup work started by [startLeave]. + * @property cancelledCall the cancelled call-scope job whose children and `finally` blocks may + * still be finishing. + */ private data class PendingJobs( val cleanup: Job?, val cancelledCall: Job?, ) + /** + * Protects transitions involving multiple lifecycle fields. + * + * Individual atomic or volatile fields would not make compound operations such as capturing + * both pending jobs or clearing all state after a successful restart atomic. + */ private val lock = Any() + + /** + * The asynchronous job created when `Call.leave()` starts. + * + * Because leave returns before cleanup finishes, [prepareForJoin] retains and waits for this job + * before attempting to restart the same Call instance. + */ private var cleanupJob: Job? = null + + /** + * The job backing the previous call coroutine scope after that scope has been cancelled. + * + * Cancellation is not completion: child coroutines and `finally` blocks may still be running, + * so [prepareForJoin] waits for this job before creating the next call lifecycle. + */ private var cancelledCallJob: Job? = null + + /** + * Whether terminal cleanup has already run for the current Call lifecycle. + * + * This makes repeated calls to [CallLifecycleManager.cleanup] idempotent. A successful + * [prepareForJoin] resets it for the newly created lifecycle, so a later cleanup is respected. + */ private var cleanedUp = false - @Volatile - private var destroyed = false + /** + * Terminal marker for the current Call lifecycle. + * + * Both `Call.leave()` through [startLeave] and direct cleanup through [cleanupOnce] set this to + * true. It remains true while asynchronous cleanup finishes and if restart fails. Only a later + * successful [prepareForJoin] clears it for the newly created lifecycle. A leave or cleanup + * invoked after that restart sets it to true again and therefore remains the final action. + */ + private val destroyed = AtomicBoolean(false) /** True after leave starts and until cleanup completes and the Call is restarted. */ - val isDestroyed: Boolean get() = destroyed + val isDestroyed: Boolean get() = destroyed.get() /** Runs leave setup once and stores the asynchronous cleanup job before releasing the lock. */ fun startLeave(createCleanupJob: () -> Job) { synchronized(lock) { - if (destroyed) return - destroyed = true + if (!destroyed.compareAndSet(false, true)) return cleanupJob = createCleanupJob() } } - /** Runs terminal cleanup once while keeping its job bookkeeping atomic with joining. */ + /** Runs terminal cleanup once and marks the current Call lifecycle as destroyed. */ fun cleanupOnce(cleanup: () -> Unit) { synchronized(lock) { if (cleanedUp) return cleanedUp = true - destroyed = true + destroyed.set(true) cleanup() } } @@ -64,30 +136,66 @@ internal class CallCleanupState { } } - /** Waits for cleanup and cancellation, then atomically restores the active lifecycle state. */ + /** + * Waits for cleanup and cancellation, then atomically restores the active lifecycle state. + * + * ```text + * Call.join() + * ↓ + * Read leave cleanup job + * ↓ + * Wait for leave cleanup + * ↓ + * Read old call scope job + * ↓ + * Wait for old call scope to fully finish + * ↓ + * Check that no newer cleanup started + * ↓ + * Restart destroyed Call + * ↓ + * Allow join to continue + * ``` + */ suspend fun prepareForJoin(restart: () -> Result): Result { - while (true) { - val pending = synchronized(lock) { - PendingJobs(cleanupJob, cancelledCallJob) - } - pending.cleanup?.join() - pending.cancelledCall?.join() + val pending = withTimeoutOrNull(CLEANUP_TIMEOUT_MS) { + val cleanup = synchronized(lock) { cleanupJob } + cleanup?.join() - val restartResult = synchronized(lock) { - if (cleanupJob !== pending.cleanup || cancelledCallJob !== pending.cancelledCall) { - return@synchronized null - } - if (destroyed) { - val result = restart() - if (result !is Success) return@synchronized result - destroyed = false - cleanupJob = null - cancelledCallJob = null - cleanedUp = false + val cancelledCall = synchronized(lock) { cancelledCallJob } + cancelledCall?.join() + + PendingJobs(cleanup, cancelledCall) + } ?: return Failure( + Error.GenericError( + "Timed out waiting for the previous Call cleanup to finish.", + ), + ) + + return synchronized(lock) { + if (cleanupJob !== pending.cleanup || cancelledCallJob !== pending.cancelledCall) { + return@synchronized if (destroyed.get()) { + Failure( + Error.GenericError( + "The Call was left or cleaned up while preparing to join.", + ), + ) + } else { + // Another concurrent join already restarted this Call. + Success(Unit) } - Success(Unit) } - if (restartResult != null) return restartResult + + if (!destroyed.get()) return@synchronized Success(Unit) + + val result = restart() + if (result !is Success) return@synchronized result + + destroyed.set(false) + cleanupJob = null + cancelledCallJob = null + cleanedUp = false + Success(Unit) } } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt index 74017e180da..9588620791f 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/MicrophoneManagerTest.kt @@ -154,7 +154,7 @@ class MicrophoneManagerTest { val audioManager = mockk(relaxed = true) every { context.getSystemService(Context.AUDIO_SERVICE) } returns audioManager every { context.getSystemService(AudioManager::class.java) } returns audioManager - val microphone = MicrophoneManager(mediaManager, audioUsage, audioUsageProvider) + val microphone = MicrophoneManager(mediaManager, audioUsageProvider) microphone.setup() microphone.cleanup() microphone.setup() diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallCleanupStateTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallCleanupStateTest.kt index db5fbe154c7..cf5f5fbfc94 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallCleanupStateTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallCleanupStateTest.kt @@ -28,12 +28,28 @@ import kotlin.test.assertTrue class CallCleanupStateTest { + @Test + fun `joining an active call does not restart its lifecycle`() = runTest { + val cleanupState = CallCleanupState() + var restartInvoked = false + + val result = cleanupState.prepareForJoin { + restartInvoked = true + Success(Unit) + } + + assertEquals(Success(Unit), result) + assertFalse(restartInvoked) + assertFalse(cleanupState.isDestroyed) + } + @Test fun `failed restart keeps the call destroyed so a later join can retry`() = runTest { val cleanupState = CallCleanupState() cleanupState.startLeave { Job().apply { complete() } } + cleanupState.cleanupOnce {} val failure = Failure(Error.GenericError("scope restart failed")) assertEquals(failure, cleanupState.prepareForJoin { failure }) @@ -42,4 +58,21 @@ class CallCleanupStateTest { assertEquals(Success(Unit), cleanupState.prepareForJoin { Success(Unit) }) assertFalse(cleanupState.isDestroyed) } + + @Test + fun `leave and cleanup after restart destroy the new lifecycle`() = runTest { + val cleanupState = CallCleanupState() + cleanupState.cleanupOnce {} + assertEquals(Success(Unit), cleanupState.prepareForJoin { Success(Unit) }) + assertFalse(cleanupState.isDestroyed) + + var cleanupInvoked = false + cleanupState.startLeave { + Job().apply { complete() } + } + cleanupState.cleanupOnce { cleanupInvoked = true } + + assertTrue(cleanupInvoked) + assertTrue(cleanupState.isDestroyed) + } } From c924046ed3b4f05bc0397e3033430c10052449fd Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 13:49:28 +0530 Subject: [PATCH 10/20] Update Kdoc --- .../call/scope/CallScopeGenerationManager.kt | 38 +++++++++++++- .../core/call/scope/RestartableStateFlow.kt | 51 +++++++++++++++---- .../call/scope/RestartableStateFlowTest.kt | 28 ++++++---- 3 files changed, 94 insertions(+), 23 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt index faa92f889ec..5850a4f5b94 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/CallScopeGenerationManager.kt @@ -30,7 +30,19 @@ import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.CoroutineContext -/** Manages the state and lifecycle transitions of the current Call coroutine scope generation. */ +/** + * Owns the coroutine scope and RTC executor used by one active Call lifecycle. + * + * A generation is created when the `Call` is created. `Call.leave()`, `Call.end()`, or + * `Call.cleanup()` eventually invokes [cleanup] through [ScopeProviderImpl.cleanup], marking that + * generation as unusable and returning its resources for cancellation. If the same Call instance + * is joined again, `Call.join()` invokes [restart] through the Call restart coordinator and + * [ScopeProviderImpl.restart], replacing the cleaned generation with a fresh scope and job. + * + * During `Call.join()`, creation of a new RTC session invokes [requireActive] and + * [getOrCreateExecutor] through [ScopeProviderImpl]. All transitions are synchronized so RTC + * session creation, cleanup, and restart cannot modify the generation concurrently. + */ internal class CallScopeGenerationManager( private val parentCoroutineContext: CoroutineContext, ) { @@ -62,10 +74,22 @@ internal class CallScopeGenerationManager( internal val currentDebugInstanceId: Int get() = current.get().debugInstanceId + /** + * Verifies that the current generation can create RTC session scopes. + * + * Invoked by `Call.join()` while constructing an RTC session through + * [ScopeProviderImpl.getCoroutineScope]. + */ internal fun requireActive(): Int = synchronized(lifecycleLock) { current.get().also(::checkActive).debugInstanceId } + /** + * Returns the current generation's dedicated RTC executor, creating it on first use. + * + * Invoked by `Call.join()` while constructing an RTC session through + * [ScopeProviderImpl.getRtcSessionScope]. + */ internal fun getOrCreateExecutor( callId: String, ): ExecutorService = synchronized(lifecycleLock) { @@ -77,6 +101,12 @@ internal class CallScopeGenerationManager( } } + /** + * Marks the current generation as cleaned and detaches its resources exactly once. + * + * Invoked through [ScopeProviderImpl.cleanup] by `Call.leave()`, `Call.end()`, or + * `Call.cleanup()`. + */ internal fun cleanup(): CleanupResult = synchronized(lifecycleLock) { val generation = current.get() if (generation.isCleanedUp) { @@ -99,6 +129,12 @@ internal class CallScopeGenerationManager( } } + /** + * Replaces a cleaned generation with a fresh Call scope and job. + * + * Invoked through [ScopeProviderImpl.restart] when `Call.join()` reuses the same Call instance + * after its previous lifecycle was cleaned up. + */ internal fun restart(): Result = synchronized(lifecycleLock) { if (!current.get().isCleanedUp) { return@synchronized Failure( diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt index fb52e0f5c63..43b04c1a622 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlow.kt @@ -30,8 +30,34 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch /** - * Keeps state storage and subscriber identity across call scopes. Sharing commands are driven - * by the exposed state's subscribers, so WhileSubscribed does not collect without subscribers. + * A [StateFlow] whose state and collector subscriptions survive Call coroutine-scope restarts. + * + * ## What it is + * + * A regular `Flow.stateIn(callScope, ...)` permanently binds its producer to one Call scope. After + * `Call.leave()` cancels that scope, the StateFlow object remains accessible but its upstream is no + * longer collected. It therefore cannot produce values when the same Call instance is joined again. + * + * [RestartableStateFlow] separates the stable [StateFlow] storage from its producer coroutine. The + * storage and its existing collectors remain unchanged, while this flow uses [scopeProvider] to + * start a new producer in each Call-scope generation. A restart resets [value] to [initialValue] + * and reconnects [upstream] using the configured [started] policy. + * + * ## How to create it + * + * Pass the upstream flow and the same [ScopeProviderImpl] that owns the reusable Call lifecycle: + * + * ```kotlin + * val duration: StateFlow = RestartableStateFlow( + * upstream = durationFlow, + * scopeProvider = scopeProvider, + * initialValue = null, + * started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 10_000L), + * ) + * ``` + * + * Use this instead of `stateIn(callScope, ...)` for derived Call state that must resume producing + * values after `leave()` followed by another `join()` on the same Call instance. */ @OptIn(InternalCoroutinesApi::class, ExperimentalForInheritanceCoroutinesApi::class) internal class RestartableStateFlow( @@ -48,12 +74,21 @@ internal class RestartableStateFlow( restartProducer() } + /** + * Starts the upstream producer in the current Call scope while preserving the configured + * [SharingStarted] behaviour of `Flow.stateIn`. + * + * Collecting a `shareIn` result into [state] would introduce a permanent internal subscriber. + * With `SharingStarted.WhileSubscribed`, that subscriber would keep [upstream] active even when + * nobody is collecting this StateFlow. Instead, the sharing commands are derived from the + * actual subscribers of [state]: [SharingCommand.START] collects [upstream], + * [SharingCommand.STOP] stops that collection, and + * [SharingCommand.STOP_AND_RESET_REPLAY_CACHE] restores [initialValue]. [collectLatest] + * cancels the previous command's upstream collection whenever the sharing command changes. + */ private fun restartProducer() { producerJob?.cancel() state.value = initialValue - // TODO: Revisit whether this manual SharingStarted implementation can be simplified. - // It currently uses the exposed StateFlow's subscriber count to preserve stateIn's - // WhileSubscribed and replay-reset behavior across scope restarts. producerJob = scopeProvider.callScope.launch { started.command(state.subscriptionCount).distinctUntilChanged().collectLatest { command -> when (command) { @@ -71,9 +106,3 @@ internal class RestartableStateFlow( state.collect(collector) } } - -internal fun Flow.restartableStateIn( - scopeProvider: ScopeProviderImpl, - started: SharingStarted, - initialValue: T, -): RestartableStateFlow = RestartableStateFlow(this, scopeProvider, initialValue, started) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt index 6b8fc41966d..bb48fdad3ef 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/scope/RestartableStateFlowTest.kt @@ -61,13 +61,18 @@ class RestartableStateFlowTest { fun `while subscribed starts and stops upstream using actual subscriber count`() = runTest { val scope = ScopeProviderImpl(backgroundScope, "default:test-call") var active = 0 - val state = flow { - active++ - try { - emit(1) - awaitCancellation() - } finally { active-- } - }.restartableStateIn(scope, SharingStarted.WhileSubscribed(stopTimeoutMillis = 0), 0) + val state = RestartableStateFlow( + upstream = flow { + active++ + try { + emit(1) + awaitCancellation() + } finally { active-- } + }, + scopeProvider = scope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 0), + initialValue = 0, + ) runCurrent() assertEquals(0, active) val collector = backgroundScope.launch { state.collect {} } @@ -92,10 +97,11 @@ class RestartableStateFlowTest { fun `replay expiration restores initial value`() = runTest { val scope = ScopeProviderImpl(backgroundScope, "default:test-call") val upstream = MutableStateFlow(1) - val state = upstream.restartableStateIn( - scope, - SharingStarted.WhileSubscribed(replayExpirationMillis = 0), - 0, + val state = RestartableStateFlow( + upstream = upstream, + scopeProvider = scope, + started = SharingStarted.WhileSubscribed(replayExpirationMillis = 0), + initialValue = 0, ) val collector = backgroundScope.launch { state.collect {} } runCurrent() From fd9f308d878bd039bbba763545d8283ec0474043 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 13:54:26 +0530 Subject: [PATCH 11/20] Use correct variable name --- .../android/core/call/components/CallJoinCoordinator.kt | 4 ++-- .../core/analytics/call/observer/JoinAnalyticsTest.kt | 4 ++-- .../core/call/components/CallJoinCoordinatorTest.kt | 7 ++++--- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt index 52253e208b6..7ad6df3905f 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinator.kt @@ -75,7 +75,7 @@ internal class CallJoinCoordinator( ) { private companion object { const val JOIN_FLIGHT_KEY = "join" - const val CALL_RESTART_FAILED = "CALL_RESTART_FAILED" + const val DESTROYED_CALL_JOIN_FAILED = "DESTROYED_CALL_JOIN_FAILED" } private val logger by taggedLogger("Call:JoinCoordinator:$type:$id") @@ -142,7 +142,7 @@ internal class CallJoinCoordinator( is Failure -> { state._connection.value = RealtimeConnection.Failed(preparation.value) callAnalytics.joinAnalytics.onJoinPreparationError( - failureCode = CALL_RESTART_FAILED, + failureCode = DESTROYED_CALL_JOIN_FAILED, message = preparation.value.message, ) return preparation diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt index 74ae6ecd2f1..2f622af0923 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/analytics/call/observer/JoinAnalyticsTest.kt @@ -177,7 +177,7 @@ class JoinAnalyticsTest { joinAnalytics.onJoinFunctionStart() joinAnalytics.onJoinPreparationError( - failureCode = "CALL_RESTART_FAILED", + failureCode = "DESTROYED_CALL_JOIN_FAILED", message = "scope restart failed", ) @@ -187,7 +187,7 @@ class JoinAnalyticsTest { false, 0, "scope restart failed", - "CALL_RESTART_FAILED", + "DESTROYED_CALL_JOIN_FAILED", null, ) } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt index 5d559cdc9a5..445ddf6f3ca 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/call/components/CallJoinCoordinatorTest.kt @@ -235,15 +235,16 @@ class CallJoinCoordinatorTest { ) { val error = Error.GenericError("scope restart failed") coEvery { lifecycle.prepareForJoin() } returns Failure(error) + val joinAnalytics = callAnalytics.joinAnalytics val result = coordinator().join() assertThat(result).isEqualTo(Failure(error)) assertThat(connectionFlow.value).isEqualTo(RealtimeConnection.Failed(error)) - verify(exactly = 1) { callAnalytics.joinAnalytics.onJoinFunctionStart() } + verify(exactly = 1) { joinAnalytics.onJoinFunctionStart() } verify(exactly = 1) { - callAnalytics.joinAnalytics.onJoinPreparationError( - failureCode = "CALL_RESTART_FAILED", + joinAnalytics.onJoinPreparationError( + failureCode = "DESTROYED_CALL_JOIN_FAILED", message = "scope restart failed", ) } From 03c68eacba2f571b3a142c2b52fabf4a1f6d85e1 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 14:51:09 +0530 Subject: [PATCH 12/20] Update kdoc --- .../call/components/CallLifecycleManager.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt index 5debc62bd21..8af9f28a82a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -25,7 +25,6 @@ import io.getstream.video.android.core.SdkCause import io.getstream.video.android.core.StreamVideoClient import io.getstream.video.android.core.analytics.call.CallAnalytics import io.getstream.video.android.core.utils.safeCall -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -86,6 +85,18 @@ internal class CallLifecycleManager( internalLeave(CallLeaveReason.Custom(reason)) } + /** + * Starts the terminal leave operation for the current Call lifecycle. + * + * Leave must run through [CallCleanupState.startLeave] because multiple SDK paths may request it + * concurrently. The cleanup state atomically accepts only the first request, marks the current + * lifecycle as destroyed, and retains the asynchronous leave job. A later `Call.join()` can then + * wait for that job instead of recreating the Call while leave signaling or cleanup is still in + * progress. + * + * The asynchronous work runs on the client scope because [cleanup] cancels the Call scope that + * initiated leave. Running it on the Call scope could cancel the leave event before it is sent. + */ private fun internalLeave(reason: CallLeaveReason) { cleanupState.startLeave { stopConnectionMonitors() @@ -102,7 +113,7 @@ internal class CallLifecycleManager( callRegistry.detach() - clientImpl.scope.launch(start = CoroutineStart.LAZY) { + clientImpl.scope.launch { try { val leaveReason = "[reason=${reason::class.simpleName}, message=${reason.message}]" callAnalytics.onCallLeave(sessionManager.session, reason) @@ -116,7 +127,7 @@ internal class CallLifecycleManager( } finally { cleanup() } - }.also { it.start() } + } } } @@ -134,6 +145,16 @@ internal class CallLifecycleManager( return result } + /** + * Performs terminal resource cleanup for the current Call lifecycle. + * + * Cleanup must run through [CallCleanupState.cleanupOnce] because it can be reached from the + * asynchronous leave `finally` block and from direct cleanup paths. The cleanup state ensures + * these paths cannot release the same session, media, and coroutine resources more than once. + * It also marks the lifecycle as destroyed and records the cancelled Call job before cleanup + * completes, allowing a later `Call.join()` to wait for the old scope's children and `finally` + * blocks before restarting the Call. + */ fun cleanup() { cleanupState.cleanupOnce { try { From a35a253428bb8c23cb66dfef718d31e9e888e310 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 14:56:28 +0530 Subject: [PATCH 13/20] Simplify cleanup --- .../call/components/CallLifecycleManager.kt | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt index 8af9f28a82a..4a221c6f88c 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -157,18 +157,12 @@ internal class CallLifecycleManager( */ fun cleanup() { cleanupState.cleanupOnce { - try { - state.cleanup() - sessionManager.session.value?.cleanup() - } finally { - cleanupState.recordCancelledCallJob(shutDownJobs()) - try { - statsReporter.stop() - media.cleanup() - } finally { - sessionManager.setActiveSession(null) - } - } + state.cleanup() + sessionManager.session.value?.cleanup() + cleanupState.recordCancelledCallJob(shutDownJobs()) + statsReporter.stop() + media.cleanup() + sessionManager.setActiveSession(null) } } From 0d5764466272b1f523ac2fed49fe64b7fa4dbd35 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 14:59:29 +0530 Subject: [PATCH 14/20] Simplify internalLeave --- .../call/components/CallLifecycleManager.kt | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt index 4a221c6f88c..4877a1a56c5 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -114,19 +114,17 @@ internal class CallLifecycleManager( callRegistry.detach() clientImpl.scope.launch { - try { - val leaveReason = "[reason=${reason::class.simpleName}, message=${reason.message}]" - callAnalytics.onCallLeave(sessionManager.session, reason) - safeCall { - sessionManager.session.value?.sfuTracer?.trace("leave-call", leaveReason) - val stats = statsReporter.collectStats() - sessionManager.session.value?.sendCallStats(stats) - } - // Must complete before cleanup() cancels the session's supervisor job. - safeCall { sessionManager.session.value?.sendLeaveEvent(leaveReason) } - } finally { - cleanup() + val leaveReason = "[reason=${reason::class.simpleName}, message=${reason.message}]" + callAnalytics.onCallLeave(sessionManager.session, reason) + safeCall { + sessionManager.session.value?.sfuTracer?.trace("leave-call", leaveReason) + val stats = statsReporter.collectStats() + sessionManager.session.value?.sendCallStats(stats) } + // Must complete before cleanup() cancels the session's supervisor job. + safeCall { sessionManager.session.value?.sendLeaveEvent(leaveReason) } + + cleanup() } } } From 294c0ef8c1b00c08467e8bb4510b4a361406c8d6 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 17:00:35 +0530 Subject: [PATCH 15/20] Update kdoc --- .../core/call/components/CallCleanupState.kt | 4 +-- .../call/components/CallLifecycleManager.kt | 2 +- .../core/call/components/CallMediaManager.kt | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt index 49485b5560a..e3b2baa8345 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallCleanupState.kt @@ -41,8 +41,8 @@ import java.util.concurrent.atomic.AtomicBoolean * and session-specific state. * * Cache access through `StreamVideo.call(type, id)` is coordinated separately by - * `StreamVideoClient`; this class only owns synchronization between leave, cleanup, and rejoin for - * one retained `Call` instance. + * `StreamVideoClient`; this class only owns synchronization between leave, cleanup, and restarting + * one retained `Call` instance after leave. */ internal class CallCleanupState { private companion object { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt index 4877a1a56c5..ceb7cc23c7a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallLifecycleManager.kt @@ -40,7 +40,7 @@ internal class CallLifecycleManager( private val clientImpl: StreamVideoClient, private val sessionManager: CallSessionManager, private val callRegistry: ClientCallRegistry, - /** Detaches and cancels the call scope, returning its job so rejoin can await cancellation. */ + /** Detaches and cancels the call scope, returning its job so restart after leave can await it. */ private val shutDownJobs: () -> Job?, private val restartCoordinator: CallRestartCoordinator, private val state: CallState, diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt index af4c5d78c22..50adc9d5b0a 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/components/CallMediaManager.kt @@ -63,7 +63,18 @@ internal class CallMediaManager( private val eglBase: () -> EglBase, private val mediaManagerFactory: MediaManagerFactory, ) : Restartable { + /** + * Camera state before leave forcibly disables capture. Without restoring it when the Call is + * restarted after leave, an enabled camera would remain `Disabled` and skip the server default + * applied only to `NotSelected`. `null` means there is no pending state to restore. + */ private var cameraEnabledBeforeLeave: Boolean? = null + + /** + * Microphone state before leave forcibly disables capture. Without restoring it when the Call + * is restarted after leave, an enabled microphone would remain `Disabled` and skip the server + * default applied only to `NotSelected`. `null` means there is no pending state to restore. + */ private var microphoneEnabledBeforeLeave: Boolean? = null private val logger by taggedLogger("Call:MediaManager:$type:$id") @@ -74,6 +85,14 @@ internal class CallMediaManager( } }) private val audioLevelOutputHelper = RampValueUpAndDownHelper() + + /** + * Collects microphone audio levels for the current Call-scope generation. + * + * Leaving the Call cancels the generation that owns this job. [startAudioLevelMonitoring] + * cancels any previous reference and replaces it with a collector in the current scope, so + * restarting the Call after leave resumes level updates without running duplicate collectors. + */ private var audioLevelMonitoringJob: Job? = null /** Smoothed local microphone volume level (0..1). */ @@ -257,6 +276,10 @@ internal class CallMediaManager( camera.setEnabled(callSettings.video.cameraDefaultOn) } + /** + * Restores the camera state captured during leave when a destroyed Call is restarted. + * The saved value is cleared after applying it so it affects only the first join after leave. + */ cameraEnabledBeforeLeave?.let { camera.setEnabled(it) } cameraEnabledBeforeLeave = null @@ -265,6 +288,10 @@ internal class CallMediaManager( val enabled = callSettings.audio.micDefaultOn microphone.setEnabled(enabled) } + /** + * Restores the microphone state captured during leave when a destroyed Call is restarted. + * The saved value is cleared after applying it so it affects only the first join after leave. + */ microphoneEnabledBeforeLeave?.let { microphone.setEnabled(it) } microphoneEnabledBeforeLeave = null } From c7d96efb09c749de81821bc109c26d3785aac539 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 17:28:05 +0530 Subject: [PATCH 16/20] Fix ut --- .../io/getstream/video/android/core/CallStateTelecomHoldTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt index 1d16957b561..a54dfb009a9 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/CallStateTelecomHoldTest.kt @@ -64,6 +64,7 @@ internal class CallStateTelecomHoldTest : TestBase() { every { id } returns "telecom-hold-test" every { cid } returns "default:telecom-hold-test" every { events } returns MutableSharedFlow() + every { scope } returns this@CallStateTelecomHoldTest.scope } @After From fc16f71d8642a72e16be8dcf28cbfbedb5bfb6bd Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 18:55:46 +0530 Subject: [PATCH 17/20] Add reusable call debug option on demo-app --- .../getstream/video/android/CallActivity.kt | 18 ++++ .../video/android/ui/CallSettingsUi.kt | 100 ++++++++++++++---- .../android/util/RetainedCallProvider.kt | 91 ++++++++++++++++ .../video/android/util/config/AppConfig.kt | 13 +++ 4 files changed, 200 insertions(+), 22 deletions(-) create mode 100644 demo-app/src/main/kotlin/io/getstream/video/android/util/RetainedCallProvider.kt diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt b/demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt index 83250e4e0fa..8eda5940e26 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/CallActivity.kt @@ -44,6 +44,7 @@ import io.getstream.video.android.core.call.state.CallAction import io.getstream.video.android.core.e2ee.E2EEEventType import io.getstream.video.android.core.e2ee.StreamEncryptionManager import io.getstream.video.android.datastore.delegate.StreamUserDataStore +import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.ui.call.CallScreen import io.getstream.video.android.ui.call.DemoComponentFactory import io.getstream.video.android.ui.common.StreamActivityUiDelegate @@ -53,6 +54,7 @@ import io.getstream.video.android.ui.common.util.StreamCallActivityDelicateApi import io.getstream.video.android.ui.lobby.deriveE2EEKey import io.getstream.video.android.util.DemoE2eeKeys import io.getstream.video.android.util.FullScreenCircleProgressBar +import io.getstream.video.android.util.RetainedCallProvider import io.getstream.video.android.util.StreamVideoInitHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -123,6 +125,22 @@ class CallActivity : ComposeStreamCallActivity() { } } + /** + * Keeps the first Call object used by this activity when the Call Settings debug option is on. + * After leave removes that Call from the SDK cache, opening the same CID returns this retained + * instance so its next join exercises restart-after-leave behavior. + */ + @StreamCallActivityDelicateApi + override fun call( + cid: StreamCallId, + onSuccess: ((Call) -> Unit)?, + onError: ((Exception) -> Unit)?, + ) { + if (!RetainedCallProvider.instance.provide(cid, onSuccess, onError)) { + super.call(cid, onSuccess, onError) + } + } + @StreamCallActivityDelicateApi override fun onPreCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { runBlocking { diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt index fc6ab4db309..60e6bf174c2 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/CallSettingsUi.kt @@ -39,6 +39,7 @@ import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,6 +53,7 @@ import io.getstream.video.android.core.call.CallType import io.getstream.video.android.core.notifications.internal.service.CallServiceConfig import io.getstream.video.android.core.notifications.internal.service.CallServiceConfigRegistry import io.getstream.video.android.util.StreamVideoInitHelper +import io.getstream.video.android.util.config.AppConfig /** * A row in the call settings screen: one call type and its current [CallServiceConfig]. @@ -92,6 +94,7 @@ fun CallSettingsScreen( modifier: Modifier = Modifier, ) { val registry = remember { StreamVideoInitHelper.callServiceConfigRegistry } + val reuseCallAfterLeave by AppConfig.reuseCallAfterLeave.collectAsState() // Intercept the system back press so it dismisses this overlay instead of finishing the activity. BackHandler(onBack = onClose) @@ -124,7 +127,9 @@ fun CallSettingsScreen( CallSettingsContent( items = items, registryAvailable = registry != null, + reuseCallAfterLeave = reuseCallAfterLeave, onClose = onClose, + onReuseCallAfterLeaveChanged = AppConfig::setReuseCallAfterLeave, onForegroundChanged = { callType, enabled -> updateConfig(callType) { it.copy(runCallServiceInForeground = enabled) } }, @@ -146,7 +151,9 @@ fun CallSettingsScreen( private fun CallSettingsContent( items: List, registryAvailable: Boolean, + reuseCallAfterLeave: Boolean, onClose: () -> Unit, + onReuseCallAfterLeaveChanged: (Boolean) -> Unit, onForegroundChanged: (CallType, Boolean) -> Unit, onTelecomChanged: (CallType, Boolean) -> Unit, onAudioUsageChanged: (CallType, Int) -> Unit, @@ -188,40 +195,85 @@ private fun CallSettingsContent( } } - if (!registryAvailable) { - Text( - text = "Call service config registry is not available yet. " + - "Sign in / start the SDK first.", - modifier = Modifier.padding(16.dp), - style = VideoTheme.typography.bodyDefault, - color = VideoTheme.colors.textSecondary, - ) - return@Column - } - LazyColumn( modifier = Modifier .fillMaxSize() .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - items(items, key = { it.callType.name }) { item -> - CallTypeConfigCard( - item = item, - onForegroundChanged = { enabled -> - onForegroundChanged( - item.callType, - enabled, - ) - }, - onTelecomChanged = { enabled -> onTelecomChanged(item.callType, enabled) }, - onAudioUsageChanged = { usage -> onAudioUsageChanged(item.callType, usage) }, + item(key = "debug-options") { + DebugOptionsCard( + reuseCallAfterLeave = reuseCallAfterLeave, + onReuseCallAfterLeaveChanged = onReuseCallAfterLeaveChanged, ) } + + if (!registryAvailable) { + item(key = "registry-unavailable") { + Text( + text = "Call service config registry is not available yet. " + + "Sign in / start the SDK first.", + style = VideoTheme.typography.bodyDefault, + color = VideoTheme.colors.textSecondary, + ) + } + } else { + items(items, key = { it.callType.name }) { item -> + CallTypeConfigCard( + item = item, + onForegroundChanged = { enabled -> + onForegroundChanged( + item.callType, + enabled, + ) + }, + onTelecomChanged = { enabled -> onTelecomChanged(item.callType, enabled) }, + onAudioUsageChanged = { usage -> + onAudioUsageChanged( + item.callType, + usage, + ) + }, + ) + } + } } } } +@Composable +private fun DebugOptionsCard( + reuseCallAfterLeave: Boolean, + onReuseCallAfterLeaveChanged: (Boolean) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + color = VideoTheme.colors.backgroundCoreElevation1, + shape = RoundedCornerShape(16.dp), + ) + .padding(16.dp), + ) { + Text( + text = "Debug", + style = VideoTheme.typography.headingLarge, + color = VideoTheme.colors.textPrimary, + ) + Text( + text = "Retains the first Call object for a CID and uses it for the next join.", + modifier = Modifier.padding(top = 2.dp, bottom = 8.dp), + style = VideoTheme.typography.bodyEmphasis, + color = VideoTheme.colors.textSecondary, + ) + ToggleRow( + label = "Restart same Call after leave", + checked = reuseCallAfterLeave, + onCheckedChange = onReuseCallAfterLeaveChanged, + ) + } +} + @Composable private fun CallTypeConfigCard( item: CallTypeConfigItem, @@ -389,7 +441,9 @@ private fun CallSettingsContentPreview() { CallSettingsContent( items = previewCallTypeConfigItems(), registryAvailable = true, + reuseCallAfterLeave = true, onClose = {}, + onReuseCallAfterLeaveChanged = {}, onForegroundChanged = { _, _ -> }, onTelecomChanged = { _, _ -> }, onAudioUsageChanged = { _, _ -> }, @@ -408,7 +462,9 @@ private fun CallSettingsUnavailablePreview() { CallSettingsContent( items = emptyList(), registryAvailable = false, + reuseCallAfterLeave = false, onClose = {}, + onReuseCallAfterLeaveChanged = {}, onForegroundChanged = { _, _ -> }, onTelecomChanged = { _, _ -> }, onAudioUsageChanged = { _, _ -> }, diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/RetainedCallProvider.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/RetainedCallProvider.kt new file mode 100644 index 00000000000..e9ebaad5b13 --- /dev/null +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/RetainedCallProvider.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.util + +import android.util.Log +import io.getstream.video.android.core.Call +import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.model.StreamCallId +import io.getstream.video.android.util.config.AppConfig + +/** + * Retains the first [Call] used for each CID when the corresponding debug option is enabled. + * + * This provider deliberately lives outside `CallActivity` so the retained instances survive + * activity recreation. It clears them when the option is disabled or the [StreamVideo] client + * changes, preventing Calls owned by an old client from being returned. + */ +internal class RetainedCallProvider private constructor() { + + internal companion object { + private const val TAG = "ReusableCallDemo" + + val instance = RetainedCallProvider() + } + + private val lock = Any() + private val retainedCalls = mutableMapOf() + private var retainedCallsClient: StreamVideo? = null + + /** + * Returns `true` when this provider handled the request. A `false` result tells the caller to + * use its normal Call provider because the debug option is disabled. + */ + fun provide( + callId: StreamCallId, + onSuccess: ((Call) -> Unit)?, + onError: ((Exception) -> Unit)?, + ): Boolean { + if (!AppConfig.reuseCallAfterLeave.value) { + clear() + return false + } + + val streamVideo = StreamVideo.instanceOrNull() + if (streamVideo == null) { + onError?.invoke( + IllegalStateException( + "StreamVideoBuilder.build() must be called before obtaining StreamVideo instance.", + ), + ) + return true + } + + val call = getOrCreate(streamVideo, callId) + Log.i(TAG, "Using retained Call for ${call.cid}: identity=${System.identityHashCode(call)}") + onSuccess?.invoke(call) + return true + } + + private fun getOrCreate(streamVideo: StreamVideo, callId: StreamCallId): Call = + synchronized(lock) { + if (retainedCallsClient !== streamVideo) { + retainedCalls.clear() + retainedCallsClient = streamVideo + } + retainedCalls.getOrPut(callId.cid) { + streamVideo.call(callId.type, callId.id) + } + } + + private fun clear() { + synchronized(lock) { + retainedCalls.clear() + retainedCallsClient = null + } + } +} diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/config/AppConfig.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/config/AppConfig.kt index 487662f1f43..ba3736dfdb5 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/config/AppConfig.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/config/AppConfig.kt @@ -43,6 +43,7 @@ object AppConfig { private val logger by taggedLogger("RemoteConfig") private const val SHARED_PREF_NAME = "stream_demo_app" private const val SELECTED_ENV = "selected_env_v2" + private const val REUSE_CALL_AFTER_LEAVE = "reuse_call_after_leave" // Data private lateinit var environment: StreamEnvironment @@ -83,6 +84,9 @@ object AppConfig { ) val currentEnvironment = MutableStateFlow(availableEnvironments.default(BuildConfig.FLAVOR)) + /** Debug option for retaining and restarting the same Call object after leave. */ + val reuseCallAfterLeave = MutableStateFlow(false) + // Utilities private val moshi: Moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() @@ -102,6 +106,7 @@ object AppConfig { ) { // Load prefs prefs = context.getSharedPreferences(SHARED_PREF_NAME, MODE_PRIVATE) + reuseCallAfterLeave.value = prefs.getBoolean(REUSE_CALL_AFTER_LEAVE, false) try { val jsonAdapter: JsonAdapter = moshi.adapter() val selectedEnvData = prefs.getString(SELECTED_ENV, null) @@ -132,6 +137,14 @@ object AppConfig { currentEnvironment.value = environment } + /** Updates and persists the reusable-Call debug option. */ + fun setReuseCallAfterLeave(enabled: Boolean) { + reuseCallAfterLeave.value = enabled + if (::prefs.isInitialized) { + prefs.edit { putBoolean(REUSE_CALL_AFTER_LEAVE, enabled) } + } + } + fun List.fromUri(env: Uri): StreamEnvironment? { val environmentName = env.extractEnvironment() return environmentName?.let { name -> From 2a396ad53c6b49b37572b5e46a853f6b016a6e03 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 20:09:39 +0530 Subject: [PATCH 18/20] Add reusable call debug option on demo-app --- .../android/ui/join/CallJoinViewModel.kt | 7 ++++- .../android/ui/lobby/CallLobbyViewModel.kt | 3 ++- .../android/util/RetainedCallProvider.kt | 26 ++++++++++++++++--- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinViewModel.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinViewModel.kt index 2baa50c763f..67a42699038 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinViewModel.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinViewModel.kt @@ -25,12 +25,14 @@ import io.getstream.video.android.core.Call import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.data.datasource.local.InMemoryStore import io.getstream.video.android.datastore.delegate.StreamUserDataStore +import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.User import io.getstream.video.android.model.mapper.isValidCallCid import io.getstream.video.android.model.mapper.toTypeAndId import io.getstream.video.android.tooling.util.StreamBuildFlavorUtil import io.getstream.video.android.util.InitializedState import io.getstream.video.android.util.NetworkMonitor +import io.getstream.video.android.util.RetainedCallProvider import io.getstream.video.android.util.StreamVideoInitHelper import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -133,7 +135,10 @@ class CallJoinViewModel @Inject constructor( } else { "default" to newCallId } - return streamVideo.call(type = type, id = id) + return RetainedCallProvider.instance.getOrCreate( + streamVideo = streamVideo, + callId = StreamCallId(type = type, id = id), + ) } fun logOut() { diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyViewModel.kt b/demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyViewModel.kt index a31ad1f1f53..a499adaa962 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyViewModel.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/ui/lobby/CallLobbyViewModel.kt @@ -32,6 +32,7 @@ import io.getstream.video.android.datastore.delegate.StreamUserDataStore import io.getstream.video.android.model.StreamCallId import io.getstream.video.android.model.User import io.getstream.video.android.util.DemoE2eeKeys +import io.getstream.video.android.util.RetainedCallProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -70,7 +71,7 @@ class CallLobbyViewModel @Inject constructor( val call: Call by lazy { val streamVideo = StreamVideo.instance() - val call = streamVideo.call(type = callId.type, id = callId.id) + val call = RetainedCallProvider.instance.getOrCreate(streamVideo, callId) viewModelScope.launch { // create the call if it doesn't exist - this will also load the settings for the call, diff --git a/demo-app/src/main/kotlin/io/getstream/video/android/util/RetainedCallProvider.kt b/demo-app/src/main/kotlin/io/getstream/video/android/util/RetainedCallProvider.kt index e9ebaad5b13..b4bd63d45c2 100644 --- a/demo-app/src/main/kotlin/io/getstream/video/android/util/RetainedCallProvider.kt +++ b/demo-app/src/main/kotlin/io/getstream/video/android/util/RetainedCallProvider.kt @@ -66,21 +66,41 @@ internal class RetainedCallProvider private constructor() { } val call = getOrCreate(streamVideo, callId) - Log.i(TAG, "Using retained Call for ${call.cid}: identity=${System.identityHashCode(call)}") onSuccess?.invoke(call) return true } - private fun getOrCreate(streamVideo: StreamVideo, callId: StreamCallId): Call = - synchronized(lock) { + /** + * Returns the retained Call when the debug option is enabled, or delegates to the SDK's normal + * get-or-create behaviour when it is disabled. + * + * Join and lobby screens must use this entry point as well as CallActivity. Otherwise they can + * populate the SDK cache with a second Call after the retained instance has left but before the + * activity asks for it. + */ + fun getOrCreate(streamVideo: StreamVideo, callId: StreamCallId): Call { + if (!AppConfig.reuseCallAfterLeave.value) { + clear() + return streamVideo.call(callId.type, callId.id) + } + + return synchronized(lock) { if (retainedCallsClient !== streamVideo) { retainedCalls.clear() retainedCallsClient = streamVideo } retainedCalls.getOrPut(callId.cid) { streamVideo.call(callId.type, callId.id) + }.also { call -> + Log.i( + TAG, + "Using retained Call for ${call.cid}: identity=${System.identityHashCode( + call, + )}", + ) } } + } private fun clear() { synchronized(lock) { From 2e7f637f8a6e11c5600252b241dc1e1b1d72d8f5 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 20:12:55 +0530 Subject: [PATCH 19/20] Remove public constructor of PeerConnectionStats as it was not needed --- .../api/stream-video-android-core.api | 2 +- .../kotlin/io/getstream/video/android/core/CallStats.kt | 9 ++++----- 2 files changed, 5 insertions(+), 6 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 42afca739c9..e324f1934ce 100644 --- a/stream-video-android-core/api/stream-video-android-core.api +++ b/stream-video-android-core/api/stream-video-android-core.api @@ -10299,7 +10299,7 @@ public final class io/getstream/video/android/core/ParticipantState$Video : io/g } public final class io/getstream/video/android/core/PeerConnectionStats { - public fun (Lkotlinx/coroutines/CoroutineScope;)V + public fun ()V public final fun getAudioBitrateKbps ()Lkotlinx/coroutines/flow/StateFlow; public final fun getAudioCodec ()Lkotlinx/coroutines/flow/StateFlow; public final fun getAudioTargetBitrateKbps ()Lkotlinx/coroutines/flow/StateFlow; 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 001f5e7980c..33a014991d4 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 @@ -47,8 +47,7 @@ data class MediaStatsInfo( } } } - -public class PeerConnectionStats(scope: CoroutineScope) { +public class PeerConnectionStats { internal var _latency: MutableStateFlow = MutableStateFlow(0) val latency: StateFlow = _latency @@ -142,12 +141,12 @@ public data class LocalStats( val sdkVersion: String, val deviceModel: String, ) - +//TODO Rahul, make internal constructor, public class CallStats(val call: Call, val callScope: CoroutineScope) { private val logger by taggedLogger("CallStats") - val publisher = PeerConnectionStats(callScope) - val subscriber = PeerConnectionStats(callScope) + val publisher = PeerConnectionStats() + val subscriber = PeerConnectionStats() val _local = MutableStateFlow(null) val local: StateFlow = _local From 33e1ba881b2ce701d9dba066a968982801ef4fa8 Mon Sep 17 00:00:00 2001 From: Rahul Kumar Lohra Date: Tue, 27 Oct 2026 20:28:49 +0530 Subject: [PATCH 20/20] refactor --- .../main/kotlin/io/getstream/video/android/core/CallStats.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 33a014991d4..3a0ba4892c2 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 @@ -141,7 +141,7 @@ public data class LocalStats( val sdkVersion: String, val deviceModel: String, ) -//TODO Rahul, make internal constructor, + public class CallStats(val call: Call, val callScope: CoroutineScope) { private val logger by taggedLogger("CallStats")