From 8c0983226d7773d2bf6c11dd805d67687b4a47a2 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 26 Jan 2026 11:59:32 +0100 Subject: [PATCH 1/9] feat: make Call object reusable after leave() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable Call objects to be rejoined after calling leave() by making coroutine scopes recreatable. This fixes the "scope canceled" error that occurred when integrations cached Call objects and tried to rejoin after leaving. Changes: - Make scopes recreatable: Change scope, supervisorJob, scopeProvider from val to var in Call.kt - Add reset() method to ScopeProvider interface and implementation - Add resetScopes() method called after cleanup() to recreate scopes - Reset isDestroyed flag to allow rejoin - Keep Call objects in StreamVideoClient.calls map after leave() - Remove destroyedCalls workaround mechanism (no longer needed) - Remove deliverIntentToDestroyedCalls() and notifyDestroyedCalls() - Deprecate callUpdatesAfterLeave parameter (now always enabled) Benefits: - Call objects can be reused: join() -> leave() -> join() works - Fixes duplicate Call object creation issue - Eliminates need for workaround code (destroyedCalls, etc.) - CallState continues receiving updates after leave() - Backwards compatible: deprecated parameter kept for binary compat 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../io/getstream/video/android/core/Call.kt | 27 +++++++++-- .../video/android/core/StreamVideoBuilder.kt | 6 ++- .../video/android/core/StreamVideoClient.kt | 48 ++++--------------- .../android/core/call/scope/ScopeProvider.kt | 6 +++ .../core/call/scope/ScopeProviderImpl.kt | 6 +++ 5 files changed, 49 insertions(+), 44 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 13907dbe56..0506ba4e60 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 @@ -153,17 +153,17 @@ public class Call( internal var reconnectAttepmts = 0 internal val clientImpl = client as StreamVideoClient - internal val scopeProvider: ScopeProvider = ScopeProviderImpl(clientImpl.scope) + internal var scopeProvider: ScopeProvider = ScopeProviderImpl(clientImpl.scope) // Atomic controls private var atomicLeave = AtomicUnitCall() private val logger by taggedLogger("Call:$type:$id") - private val supervisorJob = SupervisorJob() + private var supervisorJob = SupervisorJob() private var callStatsReportingJob: Job? = null private var powerManager: PowerManager? = null - internal val scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) + internal var scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) /** The call state contains all state such as the participant list, reactions etc */ val state = CallState(client, this, user, scope) @@ -979,6 +979,7 @@ public class Call( session?.sendCallStats(stats) } cleanup() + resetScopes() } } @@ -1507,6 +1508,26 @@ public class Call( scopeProvider.cleanup() } + /** + * Resets the scopes to allow the Call to be reusable after leave(). + * This recreates the supervisorJob, scope, and resets the scopeProvider. + */ + private fun resetScopes() { + logger.d { "[resetScopes] Recreating scopes to make Call reusable" } + + // Reset the destroyed flag to allow rejoin + isDestroyed = false + + // Reset the scope provider to allow reuse + scopeProvider.reset() + + // Recreate supervisor job and scope + supervisorJob = SupervisorJob() + scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) + + logger.d { "[resetScopes] Scopes recreated successfully" } + } + // This will allow the Rest APIs to be executed which are in queue before leave private fun shutDownJobsGracefully() { UserScope(ClientScope()).launch { diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt index 260c7144ef..f221dbeeff 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/StreamVideoBuilder.kt @@ -90,7 +90,7 @@ import java.net.ConnectException * @property audioProcessing The audio processor used for custom modifications to audio data within WebRTC. * @property callServiceConfigRegistry The audio processor used for custom modifications to audio data within WebRTC. * @property leaveAfterDisconnectSeconds The number of seconds to wait before leaving the call after the connection is disconnected. - * @property callUpdatesAfterLeave Whether to update the call state after leaving the call. + * @property callUpdatesAfterLeave [Deprecated] This parameter is no longer needed. Call updates are now always enabled after leave(). * @property connectOnInit Determines whether the socket should automatically connect as soon as a user is set. * If `false`, the connection is established only when explicitly requested or when core SDK features * (such as audio or video calls) are used. @@ -147,6 +147,10 @@ public class StreamVideoBuilder @JvmOverloads constructor( private val appName: String? = null, private val audioProcessing: ManagedAudioProcessingFactory? = null, private val leaveAfterDisconnectSeconds: Long = 30, + @Deprecated( + message = "This parameter is no longer needed. Call updates are now always enabled after leave() to support call reusability.", + level = DeprecationLevel.WARNING, + ) private val callUpdatesAfterLeave: Boolean = false, private val enableStatsReporting: Boolean = true, @InternalStreamVideoApi 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 e80891c170..2eb0270d63 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 @@ -176,6 +176,10 @@ internal class StreamVideoClient internal constructor( internal val audioProcessing: ManagedAudioProcessingFactory? = null, internal val leaveAfterDisconnectSeconds: Long = 30, internal val appVersion: String? = null, + @Deprecated( + message = "This parameter is no longer needed. Call updates are now always enabled after leave() to support call reusability.", + level = DeprecationLevel.WARNING, + ) internal val enableCallUpdatesAfterLeave: Boolean = false, internal val enableStatsCollection: Boolean = true, internal val enableStereoForSubscriber: Boolean = true, @@ -202,24 +206,18 @@ internal class StreamVideoClient internal constructor( private val logger by taggedLogger("Call:StreamVideo") private var subscriptions = mutableSetOf() private var calls = mutableMapOf() - private val destroyedCalls = LruCache(maxSize = 100) internal val callSoundAndVibrationPlayer = CallSoundAndVibrationPlayer(context) val socketImpl = coordinatorConnectionModule.socketConnection fun onCallCleanUp(call: Call) { - if (enableCallUpdatesAfterLeave) { - logger.d { "[cleanup] Call updates are required, preserve the instance: ${call.cid}" } - destroyedCalls.put(call.hashCode(), call) - } - logger.d { "[cleanup] Removing call from cache: ${call.cid}" } - calls.remove(call.cid) + logger.d { "[cleanup] Call cleaned up but kept in cache for reuse: ${call.cid}" } + // Call remains in the 'calls' map to allow rejoin and continue receiving updates } override fun cleanup() { // remove all cached calls calls.clear() - destroyedCalls.evictAll() // stop all running coroutines scope.cancel() // call cleanup on the active call @@ -560,7 +558,7 @@ internal class StreamVideoClient internal constructor( // call level subscriptions if (selectedCid.isNotEmpty()) { calls[selectedCid]?.fireEvent(event) - notifyDestroyedCalls(event) + // No need to notify destroyed calls - calls remain in map after leave() } if (selectedCid.isNotEmpty()) { @@ -584,37 +582,7 @@ internal class StreamVideoClient internal constructor( it.session?.handleEvent(event) it.handleEvent(event) } - deliverIntentToDestroyedCalls(event) - } - } - - private fun shouldProcessDestroyedCall(event: VideoEvent, callCid: String): Boolean { - return when (event) { - is WSCallEvent -> event.getCallCID() == callCid - else -> true - } - } - - private fun deliverIntentToDestroyedCalls(event: VideoEvent) { - safeCall { - destroyedCalls.snapshot().forEach { (_, call) -> - call.let { - if (shouldProcessDestroyedCall(event, call.cid)) { - it.state.handleEvent(event) - it.handleEvent(event) - } - } - } - } - } - - private fun notifyDestroyedCalls(event: VideoEvent) { - safeCall { - destroyedCalls.snapshot().forEach { (_, call) -> - if (shouldProcessDestroyedCall(event, call.cid)) { - call.fireEvent(event) - } - } + // No need to deliver to destroyed calls - calls remain in map after leave() } } 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 4543d596e8..7a0b4c6dc3 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProvider.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProvider.kt @@ -40,4 +40,10 @@ internal interface ScopeProvider { * Cleans up resources when the provider is no longer needed. */ fun cleanup() + + /** + * Resets the provider to allow reuse after cleanup. + * This clears the cleanup flag and allows executors to be recreated. + */ + fun reset() } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderImpl.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderImpl.kt index 0a711556b1..151804b065 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderImpl.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/call/scope/ScopeProviderImpl.kt @@ -97,4 +97,10 @@ internal class ScopeProviderImpl( executor?.shutdown() executor = null } + + override fun reset() { + logger.d { "Resetting ScopeProvider to allow reuse" } + isCleanedUp = false + // executor is already null after cleanup, will be recreated on next use + } } From 64416e92d51895dfcf9133ac6e7796d75ead13ba Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 26 Jan 2026 12:20:43 +0100 Subject: [PATCH 2/9] fix: use call.scope dynamically in MediaManager for video/audio after rejoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MediaManager was storing a reference to the scope passed at initialization, which became stale after scope recreation in resetScopes(). This caused video and audio to not work when rejoining after leave(). Changes: - Remove scope parameter from MediaManagerImpl constructor - Add scope as a computed property that accesses call.scope dynamically - Update Call.kt to not pass scope when creating MediaManagerImpl This ensures MediaManager always uses the current active scope, even after it's been recreated by resetScopes(). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../src/main/kotlin/io/getstream/video/android/core/Call.kt | 1 - .../kotlin/io/getstream/video/android/core/MediaManager.kt | 5 ++++- 2 files changed, 4 insertions(+), 2 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 0506ba4e60..e1304b089b 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 @@ -344,7 +344,6 @@ public class Call( MediaManagerImpl( clientImpl.context, this, - scope, eglBase.eglBaseContext, clientImpl.callServiceConfigRegistry.get(type).audioUsage, ) { clientImpl.callServiceConfigRegistry.get(type).audioUsage } 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 6d0ac2c666..d551d7240d 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 @@ -1249,12 +1249,15 @@ class CameraManager( class MediaManagerImpl( val context: Context, val call: Call, - val scope: CoroutineScope, val eglBaseContext: EglBase.Context, @Deprecated("Use audioUsageProvider instead", replaceWith = ReplaceWith("audioUsageProvider")) val audioUsage: Int = defaultAudioUsage, val audioUsageProvider: (() -> Int) = { audioUsage }, ) { + // Use call.scope dynamically to support scope recreation after leave() + val scope: CoroutineScope + get() = call.scope + internal val camera = CameraManager(this, eglBaseContext, DefaultCameraCharacteristicsValidator()) internal val microphone = MicrophoneManager(this, audioUsage, audioUsageProvider) From cdc3724f0da9c18ed1d4b10d1551dcd7e0ac3538 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 26 Jan 2026 12:23:40 +0100 Subject: [PATCH 3/9] fix: regenerate session IDs on rejoin to enable track subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When rejoining after leave(), the Call was reusing the same sessionId and unifiedSessionId. The SFU server treats these as identifying a specific session, so reusing them prevented proper track subscription and RTC connection establishment. Changes: - Change unifiedSessionId from val to var to allow regeneration - Reset both sessionId and unifiedSessionId in resetScopes() - Generate new UUIDs to ensure fresh session identity on rejoin This ensures the server treats each rejoin as a new session, allowing proper peer connection setup and track subscription. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../main/kotlin/io/getstream/video/android/core/Call.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 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 e1304b089b..3cdb8a43a1 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 @@ -233,7 +233,7 @@ public class Call( /** Session handles all real time communication for video and audio */ internal var session: RtcSession? = null var sessionId = UUID.randomUUID().toString() - internal val unifiedSessionId = UUID.randomUUID().toString() + internal var unifiedSessionId = UUID.randomUUID().toString() internal var connectStartTime = 0L internal var reconnectStartTime = 0L @@ -1509,7 +1509,7 @@ public class Call( /** * Resets the scopes to allow the Call to be reusable after leave(). - * This recreates the supervisorJob, scope, and resets the scopeProvider. + * This recreates the supervisorJob, scope, resets the scopeProvider, and generates new session IDs. */ private fun resetScopes() { logger.d { "[resetScopes] Recreating scopes to make Call reusable" } @@ -1517,6 +1517,11 @@ public class Call( // Reset the destroyed flag to allow rejoin isDestroyed = false + // Generate new session IDs for fresh connection + sessionId = UUID.randomUUID().toString() + unifiedSessionId = UUID.randomUUID().toString() + logger.d { "[resetScopes] New sessionId: $sessionId, unifiedSessionId: $unifiedSessionId" } + // Reset the scope provider to allow reuse scopeProvider.reset() From 336d3d4c6641e117f070206dab15d69374054244 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 26 Jan 2026 12:31:22 +0100 Subject: [PATCH 4/9] fix: reset device statuses to allow media re-initialization on rejoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After leave(), device managers (camera, microphone, speaker, screen share) retained their enabled/disabled status. On rejoin, updateMediaManagerFromSettings() only initializes devices with NotSelected status, so video/audio wasn't set up. Changes: - Add reset() method to MediaManagerImpl to reset all device statuses - Change device _status fields from private to internal for reset access - Call mediaManager.reset() in resetScopes() after cleanup - Add reset methods to SpeakerManager and CameraManager for consistency This ensures video and audio tracks get properly recreated and configured when rejoining after leave(). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../io/getstream/video/android/core/Call.kt | 6 +++- .../video/android/core/MediaManager.kt | 31 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 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 3cdb8a43a1..b8e8be5bfa 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 @@ -1509,7 +1509,8 @@ public class Call( /** * Resets the scopes to allow the Call to be reusable after leave(). - * This recreates the supervisorJob, scope, resets the scopeProvider, and generates new session IDs. + * This recreates the supervisorJob, scope, resets the scopeProvider, generates new session IDs, + * and resets device statuses. */ private fun resetScopes() { logger.d { "[resetScopes] Recreating scopes to make Call reusable" } @@ -1522,6 +1523,9 @@ public class Call( unifiedSessionId = UUID.randomUUID().toString() logger.d { "[resetScopes] New sessionId: $sessionId, unifiedSessionId: $unifiedSessionId" } + // Reset device statuses to NotSelected so they get re-initialized on next join + mediaManager.reset() + // Reset the scope provider to allow reuse scopeProvider.reset() 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 d551d7240d..71663014e7 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 @@ -279,7 +279,7 @@ class ScreenShareManager( private val logger by taggedLogger("Media:ScreenShareManager") - private val _status = MutableStateFlow(DeviceStatus.NotSelected) + internal val _status = MutableStateFlow(DeviceStatus.NotSelected) val status: StateFlow = _status public val isEnabled: StateFlow = _status.mapState { it is DeviceStatus.Enabled } @@ -550,7 +550,7 @@ class MicrophoneManager( internal var priorStatus: DeviceStatus? = null // Exposed state - private val _status = MutableStateFlow(DeviceStatus.NotSelected) + internal val _status = MutableStateFlow(DeviceStatus.NotSelected) /** The status of the audio */ val status: StateFlow = _status @@ -719,6 +719,13 @@ class MicrophoneManager( setupCompleted = false } + /** + * Resets the speaker status to NotSelected to allow re-initialization on next join. + */ + fun reset() { + _status.value = DeviceStatus.NotSelected + } + fun canHandleDeviceSwitch() = audioUsageProvider.invoke() != AudioAttributes.USAGE_MEDIA // Internal logic @@ -829,7 +836,7 @@ class CameraManager( private val logger by taggedLogger("Media:CameraManager") /** The status of the camera. enabled or disabled */ - private val _status = MutableStateFlow(DeviceStatus.NotSelected) + internal val _status = MutableStateFlow(DeviceStatus.NotSelected) public val status: StateFlow = _status /** Represents whether the camera is enabled */ @@ -1174,6 +1181,13 @@ class CameraManager( setupCompleted = false } + /** + * Resets the camera status to NotSelected to allow re-initialization on next join. + */ + fun reset() { + _status.value = DeviceStatus.NotSelected + } + private fun createCameraDeviceWrapper( id: String, cameraManager: CameraManager?, @@ -1380,6 +1394,17 @@ class MediaManagerImpl( camera.cleanup() microphone.cleanup() } + + /** + * Resets device statuses to NotSelected to allow re-initialization on next join. + * Should be called after cleanup when preparing for rejoin. + */ + fun reset() { + camera._status.value = DeviceStatus.NotSelected + microphone._status.value = DeviceStatus.NotSelected + speaker._status.value = DeviceStatus.NotSelected + screenShare._status.value = DeviceStatus.NotSelected + } } fun MediaStreamTrack.trySetEnabled(enabled: Boolean) = safeCall { setEnabled(enabled) } From 33f01fc818a1a2e61af66a64561c6fc608a8fb8c Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 26 Jan 2026 12:36:43 +0100 Subject: [PATCH 5/9] fix: clear participants on rejoin to show remote video/audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After leave(), participant state with disposed video/audio tracks remained in CallState.internalParticipants. On rejoin, new tracks were received but the UI was still observing old ParticipantState objects with disposed tracks, causing remote participants to not be visible. Changes: - Call state.clearParticipants() in resetScopes() to remove stale participants - Ensures fresh ParticipantState objects are created on rejoin with new tracks - Remote participant video and audio now properly render after rejoin 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../src/main/kotlin/io/getstream/video/android/core/Call.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 b8e8be5bfa..67e04ddcf0 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 @@ -1510,7 +1510,7 @@ public class Call( /** * Resets the scopes to allow the Call to be reusable after leave(). * This recreates the supervisorJob, scope, resets the scopeProvider, generates new session IDs, - * and resets device statuses. + * resets device statuses, and clears participants. */ private fun resetScopes() { logger.d { "[resetScopes] Recreating scopes to make Call reusable" } @@ -1523,6 +1523,9 @@ public class Call( unifiedSessionId = UUID.randomUUID().toString() logger.d { "[resetScopes] New sessionId: $sessionId, unifiedSessionId: $unifiedSessionId" } + // Clear participants to remove stale video/audio tracks from previous session + state.clearParticipants() + // Reset device statuses to NotSelected so they get re-initialized on next join mediaManager.reset() From 9913f0699cbb8f8337599ad117d00ccd98bda314 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 26 Jan 2026 12:46:11 +0100 Subject: [PATCH 6/9] fix: prevent race condition between leave cleanup and rejoin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cleanup() and resetScopes() were running asynchronously in a launched coroutine. If join() was called immediately after leave(), it could run before cleanup/reset completed, causing stale state and preventing proper subscriber setup. Changes: - Add cleanupJob to track the async cleanup coroutine - Make _join() wait for cleanupJob to complete before proceeding - Clear cleanupJob after waiting This ensures all cleanup and reset completes before allowing rejoin, fixing audio subscription issues. Video tracks still need investigation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../main/kotlin/io/getstream/video/android/core/Call.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 67e04ddcf0..ae0b54f0e4 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 @@ -161,6 +161,7 @@ public class Call( private val logger by taggedLogger("Call:$type:$id") private var supervisorJob = SupervisorJob() private var callStatsReportingJob: Job? = null + private var cleanupJob: Job? = null private var powerManager: PowerManager? = null internal var scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) @@ -596,6 +597,10 @@ public class Call( ring: Boolean = false, notify: Boolean = false, ): Result { + // Wait for any pending cleanup to complete before rejoining + cleanupJob?.join() + cleanupJob = null + reconnectAttepmts = 0 sfuEvents?.cancel() sfuListener?.cancel() @@ -968,7 +973,7 @@ public class Call( (client as StreamVideoClient).onCallCleanUp(this) - clientImpl.scope.launch { + cleanupJob = clientImpl.scope.launch { safeCall { session?.sfuTracer?.trace( "leave-call", From 66945ab8708dd940e6a758fdf0e715968f3b4c0e Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 26 Jan 2026 16:08:29 +0100 Subject: [PATCH 7/9] fix: maintain API compatibility for Call reusability changes - Restore deprecated `scope` parameter in MediaManagerImpl constructor - Make reset() methods internal to avoid public API additions - Remove empty shutDownJobsGracefully() function - Fix documentation comment in MicrophoneManager Co-Authored-By: Claude Opus 4.5 --- .../io/getstream/video/android/core/Call.kt | 40 ++++++++----------- .../video/android/core/MediaManager.kt | 11 +++-- .../video/android/core/StreamVideoClient.kt | 1 - 3 files changed, 23 insertions(+), 29 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 ae0b54f0e4..69d349e7fb 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 @@ -86,8 +86,6 @@ import io.getstream.video.android.core.model.UpdateUserPermissionsData import io.getstream.video.android.core.model.VideoTrack import io.getstream.video.android.core.model.toIceServer import io.getstream.video.android.core.notifications.internal.telecom.TelecomCallController -import io.getstream.video.android.core.socket.common.scope.ClientScope -import io.getstream.video.android.core.socket.common.scope.UserScope import io.getstream.video.android.core.utils.AtomicUnitCall import io.getstream.video.android.core.utils.RampValueUpAndDownHelper import io.getstream.video.android.core.utils.StreamSingleFlightProcessorImpl @@ -343,10 +341,10 @@ public class Call( testInstanceProvider.mediaManagerCreator!!.invoke() } else { MediaManagerImpl( - clientImpl.context, - this, - eglBase.eglBaseContext, - clientImpl.callServiceConfigRegistry.get(type).audioUsage, + context = clientImpl.context, + call = this, + eglBaseContext = eglBase.eglBaseContext, + audioUsage = clientImpl.callServiceConfigRegistry.get(type).audioUsage, ) { clientImpl.callServiceConfigRegistry.get(type).audioUsage } } } @@ -1504,7 +1502,6 @@ public class Call( fun cleanup() { // monitor.stop() session?.cleanup() - shutDownJobsGracefully() callStatsReportingJob?.cancel() mediaManager.cleanup() // TODO Rahul, Verify Later: need to check which call has owned the media at the moment(probably use active call) session = null @@ -1513,12 +1510,15 @@ public class Call( } /** - * Resets the scopes to allow the Call to be reusable after leave(). - * This recreates the supervisorJob, scope, resets the scopeProvider, generates new session IDs, - * resets device statuses, and clears participants. + * Resets state to allow the Call to be reusable after leave(). + * Generates new session IDs, resets the scopeProvider, clears participants, and resets device statuses. + * + * IMPORTANT: We do NOT recreate [scope] or [supervisorJob] because [CallState] and its + * StateFlows depend on the original scope. The scope lives for the entire lifetime of + * the Call object. */ private fun resetScopes() { - logger.d { "[resetScopes] Recreating scopes to make Call reusable" } + logger.d { "[resetScopes] Resetting state to make Call reusable" } // Reset the destroyed flag to allow rejoin isDestroyed = false @@ -1537,20 +1537,12 @@ public class Call( // Reset the scope provider to allow reuse scopeProvider.reset() - // Recreate supervisor job and scope - supervisorJob = SupervisorJob() - scope = CoroutineScope(clientImpl.scope.coroutineContext + supervisorJob) - - logger.d { "[resetScopes] Scopes recreated successfully" } - } + // NOTE: We intentionally do NOT recreate supervisorJob or scope here. + // CallState's StateFlows (duration, participants, etc.) use stateIn(scope, ...) + // which captures the scope at initialization. If we recreated scope, those + // StateFlows would become dead and never emit again. - // This will allow the Rest APIs to be executed which are in queue before leave - private fun shutDownJobsGracefully() { - UserScope(ClientScope()).launch { - supervisorJob.children.forEach { it.join() } - supervisorJob.cancel() - } - scope.cancel() + logger.d { "[resetScopes] State reset successfully" } } suspend fun ring(): Result { 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 71663014e7..e0eb581e2d 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 @@ -720,9 +720,9 @@ class MicrophoneManager( } /** - * Resets the speaker status to NotSelected to allow re-initialization on next join. + * Resets the microphone status to NotSelected to allow re-initialization on next join. */ - fun reset() { + internal fun reset() { _status.value = DeviceStatus.NotSelected } @@ -1184,7 +1184,7 @@ class CameraManager( /** * Resets the camera status to NotSelected to allow re-initialization on next join. */ - fun reset() { + internal fun reset() { _status.value = DeviceStatus.NotSelected } @@ -1260,9 +1260,12 @@ class CameraManager( * @see AudioSwitch * @see BluetoothHeadsetManager */ +@Suppress("UNUSED_PARAMETER") class MediaManagerImpl( val context: Context, val call: Call, + // Deprecated: This parameter is no longer used. Scope is now obtained dynamically from call.scope + scope: CoroutineScope? = null, val eglBaseContext: EglBase.Context, @Deprecated("Use audioUsageProvider instead", replaceWith = ReplaceWith("audioUsageProvider")) val audioUsage: Int = defaultAudioUsage, @@ -1399,7 +1402,7 @@ class MediaManagerImpl( * Resets device statuses to NotSelected to allow re-initialization on next join. * Should be called after cleanup when preparing for rejoin. */ - fun reset() { + internal fun reset() { camera._status.value = DeviceStatus.NotSelected microphone._status.value = DeviceStatus.NotSelected speaker._status.value = DeviceStatus.NotSelected 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 2eb0270d63..d7d287fc5b 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 @@ -18,7 +18,6 @@ package io.getstream.video.android.core import android.content.Context import android.media.AudioAttributes -import androidx.collection.LruCache import androidx.lifecycle.Lifecycle import io.getstream.android.push.PushDevice import io.getstream.android.video.generated.models.AcceptCallResponse From dccc89c80127ad3ea26dd2ad2d1637da5f55f1ea Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Mon, 26 Jan 2026 16:13:42 +0100 Subject: [PATCH 8/9] add to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index b21be5dca2..9fde93456d 100644 --- a/.gitignore +++ b/.gitignore @@ -124,3 +124,6 @@ demo-app/src/production/play/ video-buddy-server.log video-buddy-console.log video-buddy-session.json + +# GSD workflow files +.planning/ From 4783b333d19a931e8e6a6a6c9efe8620a00e065f Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 28 Jan 2026 09:14:20 +0100 Subject: [PATCH 9/9] fix: add lifecycle lock and fix speakerphone after rejoin - Add lifecycleLock to synchronize join/leave lifecycle operations - Protect isDestroyed and cleanupJob access with synchronized blocks - Move atomicLeave reset to after successful join to prevent race conditions - Fix MicrophoneManager.setup() to restart audioHandler after cleanup - Fix AudioSwitchHandler.stop() to reset isAudioSwitchInitScheduled flag Fixes: speakerphone not working after rejoin Fixes: CoroutineCancellationException during rapid join/leave cycles Co-Authored-By: Claude Opus 4.5 --- .../io/getstream/video/android/core/Call.kt | 65 ++++++++++++----- .../video/android/core/MediaManager.kt | 71 ++++++++++--------- .../video/android/core/audio/AudioHandler.kt | 14 ++-- 3 files changed, 94 insertions(+), 56 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 2391cfb384..f31af78372 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 @@ -224,8 +224,15 @@ public class Call( */ private var sfuSocketReconnectionTime: Long? = null + /** + * Lock for synchronizing join/leave lifecycle operations. + * Protects access to isDestroyed, cleanupJob, and scope re-initialization. + */ + private val lifecycleLock = Any() + /** * Call has been left and the object is cleaned up and destroyed. + * Must be accessed under [lifecycleLock]. */ private var isDestroyed = false @@ -529,10 +536,13 @@ public class Call( var result: Result - atomicLeave = AtomicUnitCall() while (retryCount < 3) { result = _join(create, createOptions, ring, notify) if (result is Success) { + // Reset atomicLeave AFTER successful join (after cleanup is complete) + // This prevents race conditions where leave() is called during the join process + atomicLeave = AtomicUnitCall() + // we initialise the camera, mic and other according to local + backend settings // only when the call is joined to make sure we don't switch and override // the settings during a call. @@ -602,8 +612,10 @@ public class Call( notify: Boolean = false, ): Result { // Wait for any pending cleanup to complete before rejoining - cleanupJob?.join() - cleanupJob = null + // Read cleanupJob under lock to prevent race conditions + val pendingCleanup = synchronized(lifecycleLock) { cleanupJob } + pendingCleanup?.join() + // Note: cleanupJob is cleared by resetScopes() at the end of cleanup reconnectAttepmts = 0 sfuEvents?.cancel() @@ -948,11 +960,21 @@ public class Call( sfuEvents?.cancel() state._connection.value = RealtimeConnection.Disconnected logger.v { "[leave] #ringing; disconnectionReason: $disconnectionReason, call_id = $id" } - if (isDestroyed) { - logger.w { "[leave] #ringing; Call already destroyed, ignoring" } - return@atomicLeave + + // Synchronize access to isDestroyed and cleanupJob to prevent race conditions + synchronized(lifecycleLock) { + if (isDestroyed) { + logger.w { "[leave] #ringing; Call already destroyed, ignoring" } + return@atomicLeave + } + isDestroyed = true + + // Guard against overwriting cleanupJob if cleanup is already in progress + if (cleanupJob?.isActive == true) { + logger.w { "[leave] Cleanup already in progress, skipping duplicate cleanup" } + return@atomicLeave + } } - isDestroyed = true sfuSocketReconnectionTime = null @@ -976,17 +998,19 @@ public class Call( (client as StreamVideoClient).onCallCleanUp(this) - cleanupJob = clientImpl.scope.launch { - safeCall { - session?.sfuTracer?.trace( - "leave-call", - "[reason=$reason, error=${disconnectionReason?.message}]", - ) - val stats = collectStats() - session?.sendCallStats(stats) + synchronized(lifecycleLock) { + cleanupJob = clientImpl.scope.launch { + safeCall { + session?.sfuTracer?.trace( + "leave-call", + "[reason=$reason, error=${disconnectionReason?.message}]", + ) + val stats = collectStats() + session?.sendCallStats(stats) + } + cleanup() + resetScopes() } - cleanup() - resetScopes() } } @@ -1525,8 +1549,11 @@ public class Call( private fun resetScopes() { logger.d { "[resetScopes] Resetting state to make Call reusable" } - // Reset the destroyed flag to allow rejoin - isDestroyed = false + // Reset the destroyed flag and clear cleanupJob under lock to prevent race conditions + synchronized(lifecycleLock) { + isDestroyed = false + cleanupJob = null + } // Generate new session IDs for fresh connection sessionId = UUID.randomUUID().toString() 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 68dafe3edb..1fadd98bfd 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 @@ -745,40 +745,47 @@ class MicrophoneManager( audioManager?.allowedCapturePolicy = AudioAttributes.ALLOW_CAPTURE_BY_ALL } - if (canHandleDeviceSwitch() && !::audioHandler.isInitialized) { - audioHandler = AudioSwitchHandler( - context = mediaManager.context, - preferredDeviceList = listOf( - AudioDevice.BluetoothHeadset::class.java, - AudioDevice.WiredHeadset::class.java, - ) + if (preferSpeaker) { - listOf( - AudioDevice.Speakerphone::class.java, - AudioDevice.Earpiece::class.java, - ) - } else { - listOf( - AudioDevice.Earpiece::class.java, - AudioDevice.Speakerphone::class.java, - ) - }, - audioDeviceChangeListener = { devices, selected -> - logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } - - _devices.value = devices.map { it.fromAudio() } - _selectedDevice.value = selected?.fromAudio() - - setupCompleted = true - - capturedOnAudioDevicesUpdate?.invoke() - capturedOnAudioDevicesUpdate = null - }, - ) + if (canHandleDeviceSwitch()) { + if (!::audioHandler.isInitialized) { + // First time initialization + audioHandler = AudioSwitchHandler( + context = mediaManager.context, + preferredDeviceList = listOf( + AudioDevice.BluetoothHeadset::class.java, + AudioDevice.WiredHeadset::class.java, + ) + if (preferSpeaker) { + listOf( + AudioDevice.Speakerphone::class.java, + AudioDevice.Earpiece::class.java, + ) + } else { + listOf( + AudioDevice.Earpiece::class.java, + AudioDevice.Speakerphone::class.java, + ) + }, + audioDeviceChangeListener = { devices, selected -> + logger.i { "[audioSwitch] audio devices. selected $selected, available devices are $devices" } + + _devices.value = devices.map { it.fromAudio() } + _selectedDevice.value = selected?.fromAudio() + + setupCompleted = true + + capturedOnAudioDevicesUpdate?.invoke() + capturedOnAudioDevicesUpdate = null + }, + ) - logger.d { "[setup] Calling start on instance $audioHandler" } - audioHandler.start() + logger.d { "[setup] Calling start on instance $audioHandler" } + audioHandler.start() + } else { + // audioHandler exists but was stopped (cleanup was called), restart it + logger.d { "[setup] Restarting audioHandler after cleanup" } + audioHandler.start() + } } else { - logger.d { "[MediaManager#setup] Usage is MEDIA or audioHandle is already initialized" } + logger.d { "[MediaManager#setup] Usage is MEDIA" } capturedOnAudioDevicesUpdate?.invoke() } } diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt index 7c4604d0ac..e24f946c8d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/audio/AudioHandler.kt @@ -76,11 +76,15 @@ public class AudioSwitchHandler( } override fun stop() { - logger.d { "[stop] no args" } - mainThreadHandler.removeCallbacksAndMessages(null) - mainThreadHandler.post { - audioSwitch?.stop() - audioSwitch = null + synchronized(this) { + logger.d { "[stop] no args" } + mainThreadHandler.removeCallbacksAndMessages(null) + mainThreadHandler.post { + audioSwitch?.stop() + audioSwitch = null + } + // Reset flag to allow restart after stop + isAudioSwitchInitScheduled = false } }