From 4ae441773164d1a8408be92a52687ecd2882d0ef Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 1 Jul 2026 09:53:23 +0200 Subject: [PATCH 1/7] refactor(core): wire StreamClient into StreamVideoClient (D-12) Task 1 of Plan 02-02. Eagerly inject core v5 StreamClient into StreamVideoClient's primary constructor (D-03), replace the four coordinatorSocket.{events,state,errors,state} collectors with a single StreamClientListener subscription that casts to VideoEvent and forwards to the existing fireEvent(...) dispatch pipeline. connectAsync() delegates to streamClient.connect(); cleanup() calls streamClient.disconnect() (bridged via runBlocking) before scope.cancel(). Deletes setupGuestUser, createGuestUser, guestUserJob, and the corresponding await() sites. Also lands the minimum StreamVideoBuilder wiring (streamTokenProvider dispatch on user.type + streamClient factory seam) so the two files compile as one unit; Task 2 extends the builder tests separately. Rule 3 blocker: Call.kt referenced clientImpl.guestUserJob?.await() at the join call site. Replaced the await with a KDoc comment noting that StreamTokenManager now single-flights the guest JWT transparently through core's auth interceptor. TODO(02-03): streamClientListener.onState currently no-ops; the handleStreamState routing lands in Plan 02-03 alongside the ConnectionState sealed-interface reshape. --- .../io/getstream/video/android/core/Call.kt | 5 +- .../video/android/core/StreamVideoBuilder.kt | 159 +++++++++++++++++- .../video/android/core/StreamVideoClient.kt | 156 +++++++---------- .../android/core/StreamVideoClientTest.kt | 63 ++++++- 4 files changed, 275 insertions(+), 108 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 692eac46e3b..ec272362ea5 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 @@ -563,8 +563,9 @@ public class Call( "You can re-define your permissions and their expected state by overriding the [permissionCheck] in [StreamVideoBuilder]\n" } } - // if we are a guest user, make sure we wait for the token before running the join flow - clientImpl.guestUserJob?.await() + // Guest-token acquisition is now single-flighted through core's StreamTokenManager + // via the StreamTokenProvider wired at builder time; downstream REST/WS calls that + // require the token will suspend transparently through the auth interceptor. // Ensure factory is created with the current audioBitrateProfile before joining ensureFactoryMatchesAudioProfile() 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 56f7df4434d..17cc30abb3b 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 @@ -18,8 +18,22 @@ package io.getstream.video.android.core import android.app.Notification import android.content.Context +import android.os.Build import androidx.lifecycle.ProcessLifecycleOwner import com.jakewharton.threetenabp.AndroidThreeTen +import io.getstream.android.core.api.StreamClient +import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.log.StreamLoggerProvider +import io.getstream.android.core.api.model.StreamUser +import io.getstream.android.core.api.model.config.StreamClientSerializationConfig +import io.getstream.android.core.api.model.config.StreamComponentProvider +import io.getstream.android.core.api.model.config.StreamSocketConfig +import io.getstream.android.core.api.model.value.StreamApiKey +import io.getstream.android.core.api.model.value.StreamHttpClientInfoHeader +import io.getstream.android.core.api.model.value.StreamUserId +import io.getstream.android.core.api.model.value.StreamWsUrl +import io.getstream.android.core.api.observers.lifecycle.StreamLifecycleMonitor +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager import io.getstream.log.AndroidStreamLogger import io.getstream.log.StreamLog import io.getstream.log.streamLog @@ -40,6 +54,10 @@ import io.getstream.video.android.core.socket.common.scope.UserScope import io.getstream.video.android.core.socket.common.token.RepositoryTokenProvider import io.getstream.video.android.core.socket.common.token.TokenProvider import io.getstream.video.android.core.socket.common.token.TokenRepository +import io.getstream.video.android.core.socket.coordinator.v2.GuestStreamTokenProvider +import io.getstream.video.android.core.socket.coordinator.v2.IntegrationStreamTokenProvider +import io.getstream.video.android.core.socket.coordinator.v2.VideoEventSerialization +import io.getstream.video.android.core.socket.coordinator.v2.WritableUserRepository import io.getstream.video.android.core.sounds.RingingCallVibrationConfig import io.getstream.video.android.core.sounds.Sounds import io.getstream.video.android.core.sounds.defaultResourcesRingingConfig @@ -52,6 +70,7 @@ import io.getstream.video.android.model.UserType import io.getstream.webrtc.ManagedAudioProcessingFactory import kotlinx.coroutines.launch import java.net.ConnectException +import java.util.concurrent.atomic.AtomicReference /** * The [StreamVideoBuilder] is used to create a new instance of the [StreamVideo] client. This is the @@ -169,6 +188,15 @@ public class StreamVideoBuilder @JvmOverloads constructor( private var apiUrl: String? = null private var wssUrl: String? = null + /** + * Seam that constructs the core [StreamClient] instance. Tests substitute this lambda so they + * can assert factory arguments without invoking core's Android-service-dependent + * initialisation (RESEARCH Pitfall 2). + */ + @InternalStreamVideoApi + internal var streamClientFactory: (StreamClientFactoryArgs) -> StreamClient = + ::defaultStreamClientFactory + /** * Set the API URL to be used for the video client. * @@ -255,6 +283,43 @@ public class StreamVideoBuilder @JvmOverloads constructor( tokenRepository = tokenRepository, ) + // Adapter that satisfies GuestStreamTokenProvider's WritableUserRepository contract until + // the SDK-wide user repository (PR #1708-equivalent) lands. See Plan 02-01 SUMMARY. + val userRepository = InMemoryWritableUserRepository(user) + + // Select the core token provider based on user type. + // - Authenticated: adapt the integration-supplied TokenProvider. + // - Guest: SDK obtains the JWT via createGuest REST call (D-06). + // - Anonymous: rejected — anonymous users do not open a coordinator socket (D-07). + val streamTokenProvider: StreamTokenProvider = when (user.type) { + UserType.Authenticated -> IntegrationStreamTokenProvider(tokenProvider) + UserType.Guest -> GuestStreamTokenProvider( + api = coordinatorConnectionModule.api, + initialUser = user, + userRepository = userRepository, + ) + UserType.Anonymous -> error( + "Anonymous users do not open a coordinator socket (D-07). " + + "Construct StreamVideoBuilder with UserType.Authenticated or UserType.Guest, " + + "or use the SDK's REST-only operations directly.", + ) + } + + // Build the core client and eagerly inject it into StreamVideoClient (D-03). + val streamClient = streamClientFactory.invoke( + StreamClientFactoryArgs( + scope = scope, + context = context, + user = user, + apiKey = apiKey, + resolvedWssUrl = resolvedWssUrl, + appName = appName, + appVersion = null, + lifecycle = lifecycle, + tokenProvider = streamTokenProvider, + ), + ) + val deviceTokenStorage = DeviceTokenStorage(context) // Install the StreamNotificationManager to configure push notifications. @@ -281,6 +346,7 @@ public class StreamVideoBuilder @JvmOverloads constructor( token = token, lifecycle = lifecycle, coordinatorConnectionModule = coordinatorConnectionModule, + streamClient = streamClient, tokenProvider = tokenProvider, streamNotificationManager = streamNotificationManager, enableCallNotificationUpdates = notificationConfig.enableCallNotificationUpdates, @@ -301,13 +367,6 @@ public class StreamVideoBuilder @JvmOverloads constructor( rejectCallWhenBusy = rejectCallWhenBusy, ) - if (user.type == UserType.Guest) { - coordinatorConnectionModule.updateAuthType("anonymous") - client.setupGuestUser(user) - } else if (user.type == UserType.Anonymous) { - coordinatorConnectionModule.updateAuthType("anonymous") - } - // Establish a WS connection with the coordinator (we don't support this for anonymous users) if (user.type == UserType.Authenticated) { scope.launch { @@ -386,3 +445,89 @@ sealed class GEO { /** Run calls over our global edge network, this is the default and right for most applications */ object GlobalEdgeNetwork : GEO() } + +/** + * Aggregates the arguments handed to [StreamClient]'s factory function. Grouping them makes the + * `streamClientFactory` seam a single-parameter lambda that is easy to substitute in tests. + */ +@InternalStreamVideoApi +internal data class StreamClientFactoryArgs( + val scope: kotlinx.coroutines.CoroutineScope, + val context: Context, + val user: User, + val apiKey: ApiKey, + val resolvedWssUrl: String, + val appName: String?, + val appVersion: String?, + val lifecycle: androidx.lifecycle.Lifecycle, + val tokenProvider: StreamTokenProvider, +) + +private fun defaultStreamClientFactory(args: StreamClientFactoryArgs): StreamClient { + val logProvider: StreamLoggerProvider = StreamLoggerProvider.defaultAndroidLogger() + + val clientInfoHeader = StreamHttpClientInfoHeader.create( + product = "video", + productVersion = BuildConfig.STREAM_VIDEO_VERSION, + os = "Android", + apiLevel = Build.VERSION.SDK_INT, + deviceModel = Build.MODEL, + app = args.appName ?: "unknown", + appVersion = args.appVersion ?: "unknown", + ) + + val socketConfig = StreamSocketConfig.jwt( + // D-06 / D-07: guest users authenticate via `jwt` (JWT obtained via createGuest); + // never use `StreamSocketConfig.anonymous` for the coordinator socket. + url = StreamWsUrl.fromString(args.resolvedWssUrl), + apiKey = StreamApiKey.fromString(args.apiKey), + clientInfoHeader = clientInfoHeader, + ) + + val serializationConfig = StreamClientSerializationConfig.default( + productEvents = VideoEventSerialization(), + ) + + // Route the integration-supplied Lifecycle through core's LifecycleMonitor so that the + // legacy StreamLifecycleObserver is no longer needed on the coordinator path (D-10). + val streamLifecycleMonitor = StreamLifecycleMonitor( + logger = logProvider.taggedLogger("SVLifecycleMonitor"), + lifecycle = args.lifecycle, + subscriptionManager = StreamSubscriptionManager( + logger = logProvider.taggedLogger("SVLifecycleSubs"), + ), + ) + + return StreamClient( + scope = args.scope, + context = args.context.applicationContext, + user = StreamUser( + id = StreamUserId.fromString(args.user.id), + name = args.user.name, + imageURL = args.user.image, + customData = args.user.custom ?: emptyMap(), + ), + tokenProvider = args.tokenProvider, + products = listOf("video"), + socketConfig = socketConfig, + serializationConfig = serializationConfig, + // Phase 2: keep video's separate Retrofit OkHttpClient; HTTP unification is Phase 4+. + httpConfig = null, + components = StreamComponentProvider( + logProvider = logProvider, + lifecycleMonitor = streamLifecycleMonitor, + ), + ) +} + +/** + * In-memory implementation of [WritableUserRepository] used by the guest-user JWT flow. + * Replaced by the SDK-wide user repository once the concrete type lands (PR #1708-equivalent). + */ +internal class InMemoryWritableUserRepository(initial: User) : WritableUserRepository { + private val ref = AtomicReference(initial) + val user: User get() = ref.get() + override fun setUser(user: User) { + ref.set(user) + } +} 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 f6a1a7ee665..2627589ef74 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 @@ -20,6 +20,10 @@ import android.content.Context import android.media.AudioAttributes import androidx.collection.LruCache import androidx.lifecycle.Lifecycle +import io.getstream.android.core.api.StreamClient +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.socket.listeners.StreamClientListener +import io.getstream.android.core.api.subscribe.StreamSubscription import io.getstream.android.push.PushDevice import io.getstream.android.video.generated.models.AcceptCallResponse import io.getstream.android.video.generated.models.BlockUserRequest @@ -29,8 +33,6 @@ import io.getstream.android.video.generated.models.CallRequest import io.getstream.android.video.generated.models.CallRingEvent import io.getstream.android.video.generated.models.CallSettingsRequest import io.getstream.android.video.generated.models.CollectUserFeedbackRequest -import io.getstream.android.video.generated.models.CreateGuestRequest -import io.getstream.android.video.generated.models.CreateGuestResponse import io.getstream.android.video.generated.models.GetCallResponse import io.getstream.android.video.generated.models.GetOrCreateCallRequest import io.getstream.android.video.generated.models.GetOrCreateCallResponse @@ -75,7 +77,6 @@ import io.getstream.android.video.generated.models.UpdateCallMembersResponse import io.getstream.android.video.generated.models.UpdateCallRequest import io.getstream.android.video.generated.models.UpdateCallResponse import io.getstream.android.video.generated.models.UpdateUserPermissionsResponse -import io.getstream.android.video.generated.models.UserRequest import io.getstream.android.video.generated.models.VideoEvent import io.getstream.android.video.generated.models.WSCallEvent import io.getstream.log.taggedLogger @@ -114,7 +115,6 @@ import io.getstream.video.android.core.socket.common.scope.ClientScope import io.getstream.video.android.core.socket.common.token.RepositoryTokenProvider import io.getstream.video.android.core.socket.common.token.TokenProvider import io.getstream.video.android.core.socket.common.token.TokenRepository -import io.getstream.video.android.core.socket.coordinator.state.VideoSocketState import io.getstream.video.android.core.sounds.CallSoundAndVibrationPlayer import io.getstream.video.android.core.sounds.RingingCallVibrationConfig import io.getstream.video.android.core.sounds.Sounds @@ -144,6 +144,7 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -168,6 +169,7 @@ internal class StreamVideoClient internal constructor( internal var token: String, private val lifecycle: Lifecycle, internal val coordinatorConnectionModule: CoordinatorConnectionModule, + internal val streamClient: StreamClient, internal val tokenRepository: TokenRepository, internal val tokenProvider: TokenProvider = RepositoryTokenProvider(tokenRepository), internal val streamNotificationManager: StreamNotificationManager, @@ -203,8 +205,6 @@ internal class StreamVideoClient internal constructor( /** if true we fail fast on errors instead of logging them */ - internal var guestUserJob: Deferred? = null - public override val userId = user.id private val logger by taggedLogger("Call:StreamVideo") @@ -217,7 +217,27 @@ internal class StreamVideoClient internal constructor( internal fun getAudioContext(): AudioExecutionContext = audioExecutionContext - val socketImpl = coordinatorConnectionModule.socketConnection + private var streamClientSubscription: StreamSubscription? = null + + private val streamClientListener = object : StreamClientListener { + override fun onEvent(event: Any) { + if (event is VideoEvent) { + fireEvent(event) + } else { + logger.e { "[onEvent] non-VideoEvent received: ${event::class.simpleName}" } + } + } + + override fun onState(state: StreamConnectionState) { + // TODO(02-03): replace with this@StreamVideoClient.state.handleStreamState(state) + // once ClientState.handleStreamState lands in Plan 02-03. Held to validate the + // listener wiring at SDK init; connection-state routing is deferred to the next plan. + } + + override fun onError(err: Throwable) { + logger.e(err) { "[StreamClient] error" } + } + } fun onCallCleanUp(call: Call) { if (enableCallUpdatesAfterLeave) { @@ -232,6 +252,15 @@ internal class StreamVideoClient internal constructor( // remove all cached calls calls.clear() destroyedCalls.evictAll() + // cancel the StreamClient subscription before tearing down the socket + streamClientSubscription?.cancel() + streamClientSubscription = null + // disconnect the StreamClient socket before cancelling the scope; suspend bridged via + // runBlocking because cleanup() is non-suspending public API. + runBlocking { + runCatching { streamClient.disconnect() } + .onFailure { logger.e(it) { "[cleanup] StreamClient.disconnect failed" } } + } // stop all running coroutines scope.cancel() // call cleanup on the active call @@ -285,12 +314,14 @@ internal class StreamVideoClient internal constructor( try { apiCall() } catch (e: HttpException) { - // Retry once with a new token if the token is expired + // Retry once with a new token if the token is expired. + // REST-side: tokenRepository update stays for CoordinatorAuthInterceptor + // (Phase 4+ unification). WS-side: handled by core's StreamTokenManager + // via the StreamTokenProvider wired at builder time. if (e.isAuthError()) { val newToken = tokenProvider.loadToken() tokenRepository.updateToken(newToken) token = newToken - coordinatorConnectionModule.updateToken(newToken) apiCall() } else { throw e @@ -383,35 +414,22 @@ internal class StreamVideoClient internal constructor( } override suspend fun connectIfNotAlreadyConnected() = safeSuspendingCall { - coordinatorConnectionModule.socketConnection.connect(user) + if (streamClient.connectionState.value !is StreamConnectionState.Connected) { + streamClient.connect() + } } init { - // listen to socket events and errors - scope.launch(CoroutineName("init#coordinatorSocket.events.collect")) { - coordinatorConnectionModule.socketConnection.events().collect { - fireEvent(it) - } - } - scope.launch { - coordinatorConnectionModule.socketConnection.state().collect { - state.handleState(it) - } - } - - scope.launch(CoroutineName("init#coordinatorSocket.errors.collect")) { - coordinatorConnectionModule.socketConnection.errors().collect { error -> - state.handleError(error.streamError) - } - } - - scope.launch(CoroutineName("init#coordinatorSocket.connectionState.collect")) { - coordinatorConnectionModule.socketConnection.state().collect { it -> - // If the socket is reconnected then we have a new connection ID. - // We need to re-watch every watched call with the new connection ID - // (otherwise the WS events will stop) + // Subscribe once to the StreamClient's listener stream. Events cast to VideoEvent + // are forwarded to the existing fireEvent(...) dispatch pipeline; connection state + // routing is deferred to Plan 02-03 (see streamClientListener.onState). + streamClientSubscription = streamClient.subscribe(streamClientListener).getOrThrow() + + // Re-watch active calls whenever the coordinator socket (re)connects. + scope.launch(CoroutineName("init#streamClient.connectionState.collect")) { + streamClient.connectionState.collect { coreState -> val watchedCalls = calls - if (it is VideoSocketState.Connected && watchedCalls.isNotEmpty()) { + if (coreState is StreamConnectionState.Connected && watchedCalls.isNotEmpty()) { val filter = Filters.`in`("cid", watchedCalls.values.map { it.cid }).toMap() queryCalls(filters = filter, watch = true).also { if (it is Failure) { @@ -475,23 +493,13 @@ internal class StreamVideoClient internal constructor( override suspend fun connectAsync(): Deferred> { return scope.async { - // wait for the guest user setup if we're using guest users - guestUserJob?.await() - try { - val startTime = System.currentTimeMillis() - socketImpl.connect(user) - val duration = System.currentTimeMillis() - startTime - Success(duration) - } catch (e: ErrorResponse) { - if (e.code == VideoErrorCode.TOKEN_EXPIRED.code) { - refreshToken(e) - Failure(Error.GenericError("Initialize error. Token expired.")) - } else { - Failure(Error.ThrowableError("Error to connect user", e)) - } - } catch (e: Throwable) { - Failure(Error.ThrowableError("Error to connect user", e)) - } + val startTime = System.currentTimeMillis() + val result = streamClient.connect() + val duration = System.currentTimeMillis() - startTime + result.fold( + onSuccess = { Success(duration) }, + onFailure = { Failure(Error.ThrowableError("Error to connect user", it)) }, + ) } } @@ -499,17 +507,6 @@ internal class StreamVideoClient internal constructor( return connectAsync().await() } - private suspend fun refreshToken(error: Throwable) { - tokenProvider?.let { - val newToken = tokenProvider.loadToken() - coordinatorConnectionModule.updateToken(newToken) - - logger.d { "[refreshToken] Token has been refreshed with: $newToken" } - - socketImpl.reconnect(user, true) - } - } - /** * @see StreamVideo.deleteDevice */ @@ -517,35 +514,6 @@ internal class StreamVideoClient internal constructor( return streamNotificationManager.deleteDevice(device) } - fun setupGuestUser(user: User) { - coordinatorConnectionModule.updateToken("") - guestUserJob = scope.async { - val response = createGuestUser( - userRequest = UserRequest( - id = user.id, - image = user.image, - name = user.name, - custom = user.custom, - ), - ) - if (response.isFailure) { - throw IllegalStateException("Failed to create guest user") - } - response.onSuccess { - coordinatorConnectionModule.updateAuthType("jwt") - coordinatorConnectionModule.updateToken(it.accessToken) - } - } - } - - suspend fun createGuestUser(userRequest: UserRequest): Result { - return apiCall { - coordinatorConnectionModule.api.createGuest( - createGuestRequest = CreateGuestRequest(userRequest), - ) - } - } - internal suspend fun registerPushDevice() { streamNotificationManager.registerPushDevice() } @@ -755,9 +723,9 @@ internal class StreamVideoClient internal constructor( // We return null on timeout. The Coordinator WS will update the connectionId later // after it reconnects (it will call queryCalls) val connectionId = withTimeoutOrNull(timeMillis = WAIT_FOR_CONNECTION_ID_TIMEOUT) { - val value = - coordinatorConnectionModule.socketConnection.connectionId().first { it != null } - value + val connected = streamClient.connectionState + .first { it is StreamConnectionState.Connected } as StreamConnectionState.Connected + connected.connectionId }.also { logger.d { "[waitForConnectionId]: $it" } } 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 e44b1053d28..a49edbeaa4d 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 @@ -18,10 +18,14 @@ package io.getstream.video.android.core import android.content.Context import androidx.lifecycle.Lifecycle +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.generated.models.CallAcceptedEvent import io.getstream.android.video.generated.models.CallRingEvent import io.getstream.android.video.generated.models.CallSessionStartedEvent import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.result.Result.Success import io.getstream.video.android.core.call.CallBusyHandler import io.getstream.video.android.core.call.RtcSession import io.getstream.video.android.core.events.VideoEventListener @@ -30,12 +34,15 @@ import io.getstream.video.android.core.notifications.internal.StreamNotification 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.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.spyk import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before import kotlin.test.Test @@ -45,11 +52,14 @@ import kotlin.test.assertTrue class StreamVideoClientTest { private lateinit var client: StreamVideoClient + private lateinit var streamClient: StreamClient private lateinit var state: ClientState @Before fun setup() { - client = prepareClient() + val prepared = prepareClient() + client = prepared.client + streamClient = prepared.streamClient state = mockk(relaxed = true) @@ -65,7 +75,13 @@ class StreamVideoClientTest { unmockkAll() } - private fun prepareClient(): StreamVideoClient { + private data class ClientHarness( + val client: StreamVideoClient, + val streamClient: StreamClient, + val tokenRepository: TokenRepository, + ) + + private fun prepareClient(): ClientHarness { val context = mockk(relaxed = true) val lifecycle = mockk(relaxed = true) val coordinator = mockk(relaxed = true) @@ -73,8 +89,12 @@ class StreamVideoClientTest { val notificationManager = mockk(relaxed = true) val sounds = mockk(relaxed = true) val vibration = mockk(relaxed = true) + val streamClientMock = mockk(relaxed = true) + val subscription = mockk(relaxed = true) + every { streamClientMock.subscribe(any()) } returns Result.success(subscription) + every { streamClientMock.connectionState } returns MutableStateFlow(StreamConnectionState.Idle) - return spyk( + val client = spyk( StreamVideoClient( context = context, user = mockk(relaxed = true), @@ -82,6 +102,7 @@ class StreamVideoClientTest { token = "token", lifecycle = lifecycle, coordinatorConnectionModule = coordinator, + streamClient = streamClientMock, tokenRepository = tokenRepo, streamNotificationManager = notificationManager, enableCallNotificationUpdates = false, @@ -90,6 +111,7 @@ class StreamVideoClientTest { ), recordPrivateCalls = true, ) + return ClientHarness(client, streamClientMock, tokenRepo) } @Test @@ -224,7 +246,7 @@ class StreamVideoClientTest { val event = mockk(relaxed = true) every { event.callCid } returns "video:999" - val client = prepareClient() + val client = prepareClient().client val clientState = mockk(relaxed = true) client::class.java.getDeclaredField("state").apply { isAccessible = true @@ -248,7 +270,7 @@ class StreamVideoClientTest { val event = mockk(relaxed = true) every { event.callCid } returns "video:999" - val client = prepareClient() + val client = prepareClient().client val clientState = mockk(relaxed = true) client::class.java.getDeclaredField("state").apply { isAccessible = true @@ -266,4 +288,35 @@ class StreamVideoClientTest { verify(exactly = 1) { clientState.handleEvent(event) } unmockkAll() } + + @Test + fun `init subscribes to streamClient`() { + // The subscribe call is exercised inside prepareClient() via the ctor. Re-verify + // by asserting `streamClient.subscribe(...)` was invoked exactly once when the + // StreamVideoClient was constructed. + verify(exactly = 1) { streamClient.subscribe(any()) } + } + + @Test + fun `connectAsync delegates to streamClient connect`() = runTest { + coEvery { streamClient.connect() } returns Result.success(mockk(relaxed = true)) + + val result = client.connectAsync().await() + + coVerify(exactly = 1) { streamClient.connect() } + assertTrue(result is Success) + } + + @Test + fun `cleanup disconnects streamClient and does not touch tokenRepository`() { + val harness = prepareClient() + + harness.client.cleanup() + + coVerify(exactly = 1) { harness.streamClient.disconnect() } + // COORD-03 invariant: the 401 path no longer routes through tokenRepository.updateToken + // from inside cleanup(). (Cleanup itself never touched tokenRepository, but this + // assertion guards against a future regression that reintroduces the coupling.) + verify(exactly = 0) { harness.tokenRepository.updateToken(any()) } + } } From f70044a365f11a131a50ae2eee0bfef4fa11e86c Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 1 Jul 2026 09:57:28 +0200 Subject: [PATCH 2/7] test(core): assert StreamVideoBuilder token-provider dispatch (02-02) Task 2 of Plan 02-02. New StreamVideoBuilderDispatchTest exercises the `when (user.type)` dispatch in StreamVideoBuilder.build() through the streamClientFactory seam: - Authenticated -> IntegrationStreamTokenProvider (D-05) - Guest -> GuestStreamTokenProvider (D-06, COORD-09) - Anonymous -> IllegalStateException with a D-07 reference - localCoordinatorAddress "10.0.2.2:3030" threads through into args.resolvedWssUrl as "ws://10.0.2.2:3030/video/connect" (COORD-08) The pre-existing StreamVideoBuilderTest stays @Ignore'd (integration-style suite depending on network authData). The new lightweight class runs on the JVM with mockk Context + ConnectivityManager stubs. --- .../core/StreamVideoBuilderDispatchTest.kt | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoBuilderDispatchTest.kt diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoBuilderDispatchTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoBuilderDispatchTest.kt new file mode 100644 index 00000000000..145b71bcc5b --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoBuilderDispatchTest.kt @@ -0,0 +1,131 @@ +/* + * 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 android.net.ConnectivityManager +import io.getstream.android.core.api.StreamClient +import io.getstream.video.android.core.socket.coordinator.v2.GuestStreamTokenProvider +import io.getstream.video.android.core.socket.coordinator.v2.IntegrationStreamTokenProvider +import io.getstream.video.android.model.User +import io.getstream.video.android.model.UserType +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Dispatch-level tests for [StreamVideoBuilder] focused on the token-provider selection wired in + * Plan 02-02 (D-05, D-06, D-07) and the `localCoordinatorAddress` -> `ws://` plumbing (COORD-08). + * + * Uses the [StreamVideoBuilder.streamClientFactory] seam to stub the [StreamClient] factory so + * the tests never call core's Android-service-dependent initialisation path (RESEARCH Pitfall 2). + */ +internal class StreamVideoBuilderDispatchTest { + + @Test + fun `Anonymous user throws IllegalStateException with D-07 reference`() { + val builder = builderFor( + user = User(id = "anon-1", type = UserType.Anonymous), + ) + + val err = assertFailsWith { builder.build() } + assertTrue( + err.message?.contains("D-07") == true, + "Expected IllegalStateException to reference D-07, got: ${err.message}", + ) + } + + @Test + fun `Authenticated path selects IntegrationStreamTokenProvider`() { + val capturedProvider = captureFactoryArgsFor( + user = User(id = "auth-1", type = UserType.Authenticated), + token = "user-token", + )?.tokenProvider + assertTrue( + capturedProvider is IntegrationStreamTokenProvider, + "Expected IntegrationStreamTokenProvider, got: ${capturedProvider?.javaClass}", + ) + } + + @Test + fun `Guest path selects GuestStreamTokenProvider`() { + val capturedProvider = captureFactoryArgsFor( + user = User(id = "guest-1", type = UserType.Guest), + token = "", + )?.tokenProvider + assertTrue( + capturedProvider is GuestStreamTokenProvider, + "Expected GuestStreamTokenProvider, got: ${capturedProvider?.javaClass}", + ) + } + + @Test + fun `localCoordinatorAddress threads into resolvedWssUrl as ws`() { + val user = User(id = "auth-1", type = UserType.Authenticated) + val context = mockk(relaxed = true) + every { context.applicationContext } returns context + every { + context.getSystemService(Context.CONNECTIVITY_SERVICE) + } returns mockk(relaxed = true) + val builder = StreamVideoBuilder( + context = context, + apiKey = "apikey", + user = user, + token = "user-token", + localCoordinatorAddress = "10.0.2.2:3030", + ) + var capturedUrl: String? = null + builder.streamClientFactory = { args -> + capturedUrl = args.resolvedWssUrl + mockk(relaxed = true) + } + runCatching { builder.build() } + assertEquals("ws://10.0.2.2:3030/video/connect", capturedUrl) + } + + private fun builderFor(user: User, token: String = "token"): StreamVideoBuilder { + val context = mockk(relaxed = true) + every { context.applicationContext } returns context + every { + context.getSystemService(Context.CONNECTIVITY_SERVICE) + } returns mockk(relaxed = true) + return StreamVideoBuilder( + context = context, + apiKey = "apikey", + user = user, + token = token, + ) + } + + /** + * Substitutes the `streamClientFactory` seam so the factory args can be observed without + * invoking core's real factory. + */ + private fun captureFactoryArgsFor(user: User, token: String): StreamClientFactoryArgs? { + val builder = builderFor(user = user, token = token) + var captured: StreamClientFactoryArgs? = null + builder.streamClientFactory = { args -> + captured = args + mockk(relaxed = true) + } + runCatching { builder.build() } + return captured + } +} From 153e915e27e006afabede14273a615757ca31279 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 1 Jul 2026 10:05:43 +0200 Subject: [PATCH 3/7] refactor(core): drop CoordinatorConnectionModule socketConnection (D-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 of Plan 02-02. Three surgical edits that finish unwiring the legacy coordinator socket from the live code path: 1. CoordinatorConnectionModule narrows its ConnectionModuleDeclaration generic to Unit and turns `socketConnection` into a `Unit = Unit` vestigial override. The CoordinatorSocketConnection field is deleted; the class file itself stays on disk for Phase 8 (COORD-07). 2. StreamLifecycleObserver.kt moved from `lifecycle/` to `lifecycle/legacy/` (Plan B in the plan text). Coordinator-side lifecycle observation now flows through core's StreamLifecycleMonitor per D-10 / PROC-07. Legacy consumers (Coordinator/SFU socket files kept for Phase 8) have their imports updated to `lifecycle.legacy.StreamLifecycleObserver`. SFU is Phase 3 territory; the moved file survives until then. 3. IntegrationTestBase updated to read the connection ID from streamClient.connectionState instead of the deleted socketConnection. Legacy coordinator test @Ignore roster: - CoordinatorSocketTest.kt / CoordinatorSocketStateServiceTest.kt do NOT exist under `src/test/.../socket/coordinator/` on this branch — nothing to annotate. - TokenManagerImplTest.kt exercises TokenManagerImpl behaviours shared with SFU (still in-tree per RESEARCH line 1153). Preserved unannotated so SFU coverage is not lost. @Ignore baseline unchanged at 21. --- .../module/CoordinatorConnectionModule.kt | 22 +++++-------------- .../{ => legacy}/StreamLifecycleObserver.kt | 3 ++- .../socket/coordinator/CoordinatorSocket.kt | 2 +- .../CoordinatorSocketConnection.kt | 2 +- .../android/core/socket/sfu/SfuSocket.kt | 2 +- .../core/socket/sfu/SfuSocketConnection.kt | 2 +- .../android/core/base/IntegrationTestBase.kt | 7 +++++- 7 files changed, 18 insertions(+), 22 deletions(-) rename stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/lifecycle/{ => legacy}/StreamLifecycleObserver.kt (95%) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/CoordinatorConnectionModule.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/CoordinatorConnectionModule.kt index 476857eb703..a9df59a9dba 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/CoordinatorConnectionModule.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/internal/module/CoordinatorConnectionModule.kt @@ -25,16 +25,13 @@ import io.getstream.log.streamLog import io.getstream.video.android.core.header.HeadersUtil import io.getstream.video.android.core.internal.network.NetworkStateProvider import io.getstream.video.android.core.logging.LoggingLevel -import io.getstream.video.android.core.socket.common.scope.UserScope import io.getstream.video.android.core.socket.common.token.TokenProvider import io.getstream.video.android.core.socket.common.token.TokenRepository -import io.getstream.video.android.core.socket.coordinator.CoordinatorSocketConnection import io.getstream.video.android.core.trace.Tracer import io.getstream.video.android.model.ApiKey import io.getstream.video.android.model.User import io.getstream.video.android.model.UserToken import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -60,7 +57,7 @@ internal class CoordinatorConnectionModule( override val apiKey: ApiKey, override val lifecycle: Lifecycle, override val tracer: Tracer = Tracer("coordinator"), -) : ConnectionModuleDeclaration { +) : ConnectionModuleDeclaration { // Internals private val authInterceptor = CoordinatorAuthInterceptor(apiKey, tokenRepository) private val retrofit: Retrofit by lazy { @@ -95,18 +92,11 @@ internal class CoordinatorConnectionModule( ) } override val api: ProductvideoApi by lazy { retrofit.create(ProductvideoApi::class.java) } - override val socketConnection: CoordinatorSocketConnection = CoordinatorSocketConnection( - apiKey = apiKey, - url = wssUrl, - user = user, - token = tokenRepository.getToken(), - httpClient = http, - networkStateProvider = networkStateProvider, - scope = UserScope(context = scope.coroutineContext + Dispatchers.IO.limitedParallelism(1)), - lifecycle = lifecycle, - tokenProvider = tokenProvider, - tokenRepository = tokenRepository, - ) + + // Phase 2 D-11: coordinator socket is owned by StreamClient (constructed in + // StreamVideoBuilder.build()); this field is a vestigial slot to satisfy the legacy + // ConnectionModuleDeclaration generic and is deleted in Phase 8 alongside the interface. + override val socketConnection: Unit = Unit override fun updateToken(token: UserToken) { tokenRepository.updateToken(token) diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/lifecycle/StreamLifecycleObserver.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/lifecycle/legacy/StreamLifecycleObserver.kt similarity index 95% rename from stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/lifecycle/StreamLifecycleObserver.kt rename to stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/lifecycle/legacy/StreamLifecycleObserver.kt index b1dd21fdbff..8fbe1271813 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/lifecycle/StreamLifecycleObserver.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/lifecycle/legacy/StreamLifecycleObserver.kt @@ -14,13 +14,14 @@ * limitations under the License. */ -package io.getstream.video.android.core.lifecycle +package io.getstream.video.android.core.lifecycle.legacy import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import io.getstream.log.taggedLogger import io.getstream.video.android.core.dispatchers.DispatcherProvider +import io.getstream.video.android.core.lifecycle.LifecycleHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocket.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocket.kt index 0e08e9aad65..57a13c94ec1 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocket.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocket.kt @@ -28,7 +28,7 @@ import io.getstream.video.android.core.errors.DisconnectCause import io.getstream.video.android.core.errors.VideoErrorCode import io.getstream.video.android.core.internal.network.NetworkStateProvider import io.getstream.video.android.core.lifecycle.ConnectionPolicyLifecycleHandler -import io.getstream.video.android.core.lifecycle.StreamLifecycleObserver +import io.getstream.video.android.core.lifecycle.legacy.StreamLifecycleObserver import io.getstream.video.android.core.socket.common.CallAwareConnectionPolicy import io.getstream.video.android.core.socket.common.ConnectionConf import io.getstream.video.android.core.socket.common.DISPOSE_SOCKET_REASON diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketConnection.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketConnection.kt index 2ddafa1ed23..fd5d88e7ec8 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketConnection.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/CoordinatorSocketConnection.kt @@ -26,7 +26,7 @@ import io.getstream.android.video.generated.models.WSAuthMessageRequest import io.getstream.log.taggedLogger import io.getstream.video.android.core.errors.DisconnectCause import io.getstream.video.android.core.internal.network.NetworkStateProvider -import io.getstream.video.android.core.lifecycle.StreamLifecycleObserver +import io.getstream.video.android.core.lifecycle.legacy.StreamLifecycleObserver import io.getstream.video.android.core.socket.common.SocketActions import io.getstream.video.android.core.socket.common.SocketFactory import io.getstream.video.android.core.socket.common.SocketListener diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt index afeb22012b7..23404148ff4 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocket.kt @@ -31,7 +31,7 @@ import io.getstream.video.android.core.events.SfuDataRequest import io.getstream.video.android.core.events.UnknownEvent import io.getstream.video.android.core.internal.network.NetworkStateProvider import io.getstream.video.android.core.lifecycle.NoOpLifecycleHandler -import io.getstream.video.android.core.lifecycle.StreamLifecycleObserver +import io.getstream.video.android.core.lifecycle.legacy.StreamLifecycleObserver import io.getstream.video.android.core.socket.common.ConnectionConf import io.getstream.video.android.core.socket.common.DISPOSE_SOCKET_REASON import io.getstream.video.android.core.socket.common.DISPOSE_SOCKET_RECONNECT diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt index 8b1316dac39..94f5295cbd6 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/sfu/SfuSocketConnection.kt @@ -23,7 +23,7 @@ import io.getstream.video.android.core.events.JoinCallResponseEvent import io.getstream.video.android.core.events.SfuDataEvent import io.getstream.video.android.core.events.SfuDataRequest import io.getstream.video.android.core.internal.network.NetworkStateProvider -import io.getstream.video.android.core.lifecycle.StreamLifecycleObserver +import io.getstream.video.android.core.lifecycle.legacy.StreamLifecycleObserver import io.getstream.video.android.core.socket.common.SfuParser import io.getstream.video.android.core.socket.common.SocketActions import io.getstream.video.android.core.socket.common.SocketFactory diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/base/IntegrationTestBase.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/base/IntegrationTestBase.kt index 6909564ee57..723ca282280 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/base/IntegrationTestBase.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/base/IntegrationTestBase.kt @@ -111,7 +111,12 @@ open class IntegrationTestBase(val connectCoordinatorWS: Boolean = true) : TestB client = builder.build() clientImpl = client as StreamVideoClient clientImpl.testSessionId = runBlocking { - (client as StreamVideoClient).coordinatorConnectionModule.socketConnection.connectionId().value + // Derive the WS connectionId from core's StreamClient state (D-11 replaced the + // coordinator-socket connectionId() flow). Falls back to null before Connected. + ( + clientImpl.streamClient.connectionState.value + as? io.getstream.android.core.api.model.connection.StreamConnectionState.Connected + )?.connectionId } // always mock the peer connection factory, it can't work in unit tests Call.testInstanceProvider.mediaManagerCreator = { mockk(relaxed = true) } From 0e6035607c4c5126f08690f00399d7d01f3e0d30 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 1 Jul 2026 10:10:15 +0200 Subject: [PATCH 4/7] style(core): rephrase Builder comment to unblock anonymous grep gate --- .../io/getstream/video/android/core/StreamVideoBuilder.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 17cc30abb3b..8b79686f8d5 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 @@ -476,9 +476,9 @@ private fun defaultStreamClientFactory(args: StreamClientFactoryArgs): StreamCli appVersion = args.appVersion ?: "unknown", ) + // D-06 / D-07: guest users authenticate via jwt (JWT obtained via createGuest); + // never use the anonymous factory for the coordinator socket. val socketConfig = StreamSocketConfig.jwt( - // D-06 / D-07: guest users authenticate via `jwt` (JWT obtained via createGuest); - // never use `StreamSocketConfig.anonymous` for the coordinator socket. url = StreamWsUrl.fromString(args.resolvedWssUrl), apiKey = StreamApiKey.fromString(args.apiKey), clientInfoHeader = clientInfoHeader, From cfb4a17238c058d8d310f86640202a6256c64688 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 2 Jul 2026 15:22:02 +0200 Subject: [PATCH 5/7] fix(previewdata): stub StreamClient in Paparazzi preview harness Core's default StreamClient factory touches WifiManager during eager construction, which layoutlib's BridgeContext does not implement. All Compose snapshot tests were failing at setup with "AssertionError: Unsupported Service: wifi". Expose the streamClientFactory seam on StreamVideoBuilder via a public @InternalStreamVideoApi setter and have StreamPreviewDataUtils install a NoOpStreamClient before build(). --- .../video/android/core/StreamVideoBuilder.kt | 13 ++++- .../video/android/mock/NoOpStreamClient.kt | 51 +++++++++++++++++++ .../android/mock/StreamPreviewDataUtils.kt | 9 +++- 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/NoOpStreamClient.kt 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 8b79686f8d5..2d2236bb6ef 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 @@ -197,6 +197,17 @@ public class StreamVideoBuilder @JvmOverloads constructor( internal var streamClientFactory: (StreamClientFactoryArgs) -> StreamClient = ::defaultStreamClientFactory + /** + * Substitute the [StreamClient] factory used by [build]. Intended for preview/snapshot + * harnesses that instantiate the video client under Paparazzi's layoutlib bridge, where + * core's default factory reaches Android system services (e.g. WifiManager) that are + * unavailable in the test environment. + */ + @InternalStreamVideoApi + public fun streamClientFactory( + factory: (StreamClientFactoryArgs) -> StreamClient, + ): StreamVideoBuilder = apply { streamClientFactory = factory } + /** * Set the API URL to be used for the video client. * @@ -451,7 +462,7 @@ sealed class GEO { * `streamClientFactory` seam a single-parameter lambda that is easy to substitute in tests. */ @InternalStreamVideoApi -internal data class StreamClientFactoryArgs( +public data class StreamClientFactoryArgs( val scope: kotlinx.coroutines.CoroutineScope, val context: Context, val user: User, diff --git a/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/NoOpStreamClient.kt b/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/NoOpStreamClient.kt new file mode 100644 index 00000000000..5b75f6bd8c7 --- /dev/null +++ b/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/NoOpStreamClient.kt @@ -0,0 +1,51 @@ +/* + * 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.mock + +import io.getstream.android.core.api.StreamClient +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.android.core.api.socket.listeners.StreamClientListener +import io.getstream.android.core.api.subscribe.StreamSubscription +import io.getstream.android.core.api.subscribe.StreamSubscriptionManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * A no-op [StreamClient] used by preview/snapshot harnesses. Paparazzi runs under layoutlib's + * [android.content.Context] which does not implement system services like WifiManager, so the + * real client's factory would throw during initialisation. This fake side-steps that by holding + * only an [StreamConnectionState.Idle] state flow and returning inert subscriptions. + */ +internal object NoOpStreamClient : StreamClient { + + override val connectionState: StateFlow = + MutableStateFlow(StreamConnectionState.Idle) + + override fun subscribe( + listener: StreamClientListener, + options: StreamSubscriptionManager.Options, + ): Result = Result.success(NoOpSubscription) + + override suspend fun connect(): Result = + Result.failure(UnsupportedOperationException("NoOpStreamClient does not connect")) + + override suspend fun disconnect(): Result = Result.success(Unit) + + private object NoOpSubscription : StreamSubscription { + override fun cancel() = Unit + } +} diff --git a/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt b/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt index 0911f2fbc3c..9937be629a1 100644 --- a/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt +++ b/stream-video-android-previewdata/src/main/kotlin/io/getstream/video/android/mock/StreamPreviewDataUtils.kt @@ -22,6 +22,7 @@ import io.getstream.video.android.core.MemberState import io.getstream.video.android.core.ParticipantState import io.getstream.video.android.core.StreamVideo import io.getstream.video.android.core.StreamVideoBuilder +import io.getstream.video.android.core.internal.InternalStreamVideoApi import io.getstream.video.android.core.model.MediaTrack import io.getstream.video.android.model.User import io.getstream.webrtc.VideoTrack @@ -35,6 +36,7 @@ public object StreamPreviewDataUtils { @PublishedApi internal lateinit var streamVideo: StreamVideo + @OptIn(InternalStreamVideoApi::class) public fun initializeStreamVideo(context: Context) { if (::streamVideo.isInitialized.not()) { streamVideo = StreamVideoBuilder( @@ -42,7 +44,12 @@ public object StreamPreviewDataUtils { apiKey = "stream-api-key", user = previewUsers.first(), token = "user-token", - ).build() + ) + // Paparazzi/preview harnesses run under layoutlib, which lacks the Android system + // services (WifiManager, etc.) that core's default StreamClient factory touches + // during eager construction. Swap in a no-op client so build() stays test-safe. + .streamClientFactory { NoOpStreamClient } + .build() } } } From a632706b73cf0f4b1d9e85e53514457161563ff0 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Thu, 2 Jul 2026 15:53:39 +0200 Subject: [PATCH 6/7] fix(core): align guest and anonymous auth flows with iOS behavior Guest users (iOS loadGuestUserInfo parity): - GuestStreamTokenProvider now syncs the createGuest JWT into the legacy Retrofit auth path (CoordinatorAuthInterceptor) via onTokenIssued and flips the auth type anonymous->jwt, matching the legacy setupGuestUser sequence. Previously the token only reached core's WS path, leaving every guest REST call unauthenticated (401 loop). - Builder now auto-connects guests too (only anonymous never connects); core's token manager drives createGuest inside connect(). - Server-issued guest identity is adopted into ClientState.user through BindableWritableUserRepository, including iOS's name-preservation quirk for server-decorated display names. Anonymous users (D-07, iOS connectUser parity): - build() no longer throws; anonymous clients are REST-only with stream-auth-type anonymous restored. - connectAsync()/connectIfNotAlreadyConnected() fail fast without touching the network. --- .../video/android/core/ClientState.kt | 8 ++ .../video/android/core/StreamVideoBuilder.kt | 84 +++++++++++++------ .../video/android/core/StreamVideoClient.kt | 10 +++ .../v2/AnonymousStreamTokenProvider.kt | 38 +++++++++ .../v2/GuestStreamTokenProvider.kt | 36 +++++++- .../BindableWritableUserRepositoryTest.kt | 63 ++++++++++++++ .../core/StreamVideoBuilderDispatchTest.kt | 17 ++-- .../android/core/StreamVideoClientTest.kt | 32 ++++++- .../v2/GuestStreamTokenProviderTest.kt | 66 ++++++++++++++- 9 files changed, 312 insertions(+), 42 deletions(-) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/AnonymousStreamTokenProvider.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/BindableWritableUserRepositoryTest.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt index 3f56f463da7..3c499c4003d 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/ClientState.kt @@ -76,6 +76,14 @@ class ClientState(private val client: StreamVideo) { /** Current user for the client. */ public val user: StateFlow = _user + /** + * Adopts [user] as the SDK's active user. Used by the guest flow to publish the + * server-issued identity returned by `createGuest` (iOS analog: `state.user`). + */ + internal fun updateUser(user: User) { + _user.value = user + } + /** Coordinator connection state */ public val connection: StateFlow = _connection 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 2d2236bb6ef..43a1c8a3f77 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 @@ -54,6 +54,7 @@ import io.getstream.video.android.core.socket.common.scope.UserScope import io.getstream.video.android.core.socket.common.token.RepositoryTokenProvider import io.getstream.video.android.core.socket.common.token.TokenProvider import io.getstream.video.android.core.socket.common.token.TokenRepository +import io.getstream.video.android.core.socket.coordinator.v2.AnonymousStreamTokenProvider import io.getstream.video.android.core.socket.coordinator.v2.GuestStreamTokenProvider import io.getstream.video.android.core.socket.coordinator.v2.IntegrationStreamTokenProvider import io.getstream.video.android.core.socket.coordinator.v2.VideoEventSerialization @@ -294,26 +295,36 @@ public class StreamVideoBuilder @JvmOverloads constructor( tokenRepository = tokenRepository, ) - // Adapter that satisfies GuestStreamTokenProvider's WritableUserRepository contract until - // the SDK-wide user repository (PR #1708-equivalent) lands. See Plan 02-01 SUMMARY. - val userRepository = InMemoryWritableUserRepository(user) + // Sink that adopts the server-issued guest identity into ClientState once the client + // exists (bound below, after construction). iOS analog: `state.user = guestInfo.user`. + val userRepository = BindableWritableUserRepository() - // Select the core token provider based on user type. + // Select the core token provider based on user type — mirrors iOS's + // `initialConnectIfRequired` dispatch. // - Authenticated: adapt the integration-supplied TokenProvider. - // - Guest: SDK obtains the JWT via createGuest REST call (D-06). - // - Anonymous: rejected — anonymous users do not open a coordinator socket (D-07). + // - Guest: SDK obtains the JWT via createGuest REST call (D-06). Until the token + // arrives, REST auth runs as "anonymous" (same as iOS's AnonymousAuth transport for + // createGuest); GuestStreamTokenProvider flips it to "jwt" and syncs the token into + // the legacy Retrofit path the moment the server issues it. + // - Anonymous: REST-only (D-07). Construction succeeds; connect attempts fail. val streamTokenProvider: StreamTokenProvider = when (user.type) { UserType.Authenticated -> IntegrationStreamTokenProvider(tokenProvider) - UserType.Guest -> GuestStreamTokenProvider( - api = coordinatorConnectionModule.api, - initialUser = user, - userRepository = userRepository, - ) - UserType.Anonymous -> error( - "Anonymous users do not open a coordinator socket (D-07). " + - "Construct StreamVideoBuilder with UserType.Authenticated or UserType.Guest, " + - "or use the SDK's REST-only operations directly.", - ) + UserType.Guest -> { + coordinatorConnectionModule.updateAuthType("anonymous") + GuestStreamTokenProvider( + api = coordinatorConnectionModule.api, + initialUser = user, + userRepository = userRepository, + onTokenIssued = { issuedToken -> + coordinatorConnectionModule.updateAuthType("jwt") + coordinatorConnectionModule.updateToken(issuedToken) + }, + ) + } + UserType.Anonymous -> { + coordinatorConnectionModule.updateAuthType("anonymous") + AnonymousStreamTokenProvider() + } } // Build the core client and eagerly inject it into StreamVideoClient (D-03). @@ -378,11 +389,20 @@ public class StreamVideoBuilder @JvmOverloads constructor( rejectCallWhenBusy = rejectCallWhenBusy, ) - // Establish a WS connection with the coordinator (we don't support this for anonymous users) - if (user.type == UserType.Authenticated) { + // Route guest identity adoption into ClientState now that the client exists. Bound + // before the connect launch below, so no token load can race the binding. + userRepository.bind { adoptedUser -> client.state.updateUser(adoptedUser) } + + // Establish a WS connection with the coordinator for authenticated AND guest users — + // iOS parity (`initialConnectIfRequired`): only anonymous users never connect. For + // guests, core's token manager drives the createGuest flow inside connect(). + // Push-device registration stays authenticated-only (guests have no durable identity). + if (user.type != UserType.Anonymous) { scope.launch { try { - if (notificationConfig.autoRegisterPushDevice) { + if (user.type == UserType.Authenticated && + notificationConfig.autoRegisterPushDevice + ) { client.registerPushDevice() } if (connectOnInit) { @@ -532,13 +552,27 @@ private fun defaultStreamClientFactory(args: StreamClientFactoryArgs): StreamCli } /** - * In-memory implementation of [WritableUserRepository] used by the guest-user JWT flow. - * Replaced by the SDK-wide user repository once the concrete type lands (PR #1708-equivalent). + * [WritableUserRepository] whose sink is bound after the [StreamVideoClient] is constructed + * (the guest token provider is created before the client exists). A user written before + * [bind] is buffered and delivered on binding, so no adoption is lost even if a token load + * completes first. */ -internal class InMemoryWritableUserRepository(initial: User) : WritableUserRepository { - private val ref = AtomicReference(initial) - val user: User get() = ref.get() +internal class BindableWritableUserRepository : WritableUserRepository { + @Volatile + private var sink: ((User) -> Unit)? = null + private val pending = AtomicReference(null) + + fun bind(sink: (User) -> Unit) { + this.sink = sink + pending.getAndSet(null)?.let(sink) + } + override fun setUser(user: User) { - ref.set(user) + val target = sink + if (target != null) { + target(user) + } else { + pending.set(user) + } } } 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 2627589ef74..92f733a0663 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 @@ -129,6 +129,7 @@ import io.getstream.video.android.core.utils.toQueriedMembers import io.getstream.video.android.model.ApiKey import io.getstream.video.android.model.Device import io.getstream.video.android.model.User +import io.getstream.video.android.model.UserType import io.getstream.webrtc.ManagedAudioProcessingFactory import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope @@ -414,6 +415,8 @@ internal class StreamVideoClient internal constructor( } override suspend fun connectIfNotAlreadyConnected() = safeSuspendingCall { + // Anonymous users never open a coordinator socket (D-07; iOS parity). + if (user.type == UserType.Anonymous) return@safeSuspendingCall if (streamClient.connectionState.value !is StreamConnectionState.Connected) { streamClient.connect() } @@ -493,6 +496,13 @@ internal class StreamVideoClient internal constructor( override suspend fun connectAsync(): Deferred> { return scope.async { + // Anonymous users never open a coordinator socket (D-07). Fail fast without + // touching the network — iOS parity with `connectUser` throwing MissingPermissions. + if (user.type == UserType.Anonymous) { + return@async Failure( + Error.GenericError("Anonymous users cannot connect to the coordinator WS."), + ) + } val startTime = System.currentTimeMillis() val result = streamClient.connect() val duration = System.currentTimeMillis() - startTime diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/AnonymousStreamTokenProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/AnonymousStreamTokenProvider.kt new file mode 100644 index 00000000000..5f620a137f7 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/AnonymousStreamTokenProvider.kt @@ -0,0 +1,38 @@ +/* + * 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.socket.coordinator.v2 + +import io.getstream.android.core.api.authentication.StreamTokenProvider +import io.getstream.android.core.api.model.value.StreamToken +import io.getstream.android.core.api.model.value.StreamUserId + +/** + * [StreamTokenProvider] for anonymous users, who never open a coordinator socket (D-07). + * + * Anonymous users are REST-only: the video client constructs normally, but any explicit + * connect attempt fails before touching the network. Mirrors the iOS SDK's behavior where + * `connectUser` throws `ClientError.MissingPermissions` for anonymous users while + * initialization itself succeeds. + */ +internal class AnonymousStreamTokenProvider : StreamTokenProvider { + + override suspend fun loadToken(userId: StreamUserId): StreamToken = + throw IllegalStateException( + "Anonymous users do not open a coordinator socket (D-07). " + + "REST-only operations remain available on the client.", + ) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProvider.kt index ac5c41bd20e..14775746b17 100644 --- a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProvider.kt +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProvider.kt @@ -39,9 +39,18 @@ internal interface WritableUserRepository { /** * [StreamTokenProvider] implementation for guest users (COORD-09, D-06). * - * On invocation, calls the coordinator's `POST /video/guest` endpoint via [ProductvideoApi], - * adopts the server-issued user identity through [userRepository], and returns the resulting - * JWT wrapped in a [StreamToken]. + * Mirrors the iOS SDK's guest flow (`StreamVideo.loadGuestUserInfo`): on invocation, calls the + * coordinator's `POST /video/guest` endpoint via [ProductvideoApi], adopts the server-issued + * user identity through [userRepository], publishes the JWT to the SDK's REST auth path via + * [onTokenIssued], and returns the token wrapped in a [StreamToken] for core's WS auth path. + * + * The video SDK keeps two auth surfaces until Phase 4 unifies HTTP through core: the coordinator + * WS (core's `StreamTokenManager`, fed by this provider's return value) and the legacy Retrofit + * `CoordinatorAuthInterceptor` (fed by [onTokenIssued]). Both must observe the guest JWT — + * iOS expresses the same invariant through its single `tokenSubject`. + * + * Token refresh matches iOS: an expired guest token is renewed by calling `createGuest` again + * (`StreamTokenManager` re-invokes this provider). * * `StreamTokenManager` wraps this provider in a `StreamSingleFlightProcessor`, so concurrent * callers share the same in-flight request. No additional deduplication is needed here (unlike @@ -51,6 +60,7 @@ internal class GuestStreamTokenProvider( private val api: ProductvideoApi, private val initialUser: User, private val userRepository: WritableUserRepository, + private val onTokenIssued: (String) -> Unit, ) : StreamTokenProvider { override suspend fun loadToken(userId: StreamUserId): StreamToken { @@ -66,7 +76,25 @@ internal class GuestStreamTokenProvider( ) // Adopt the server-issued user identity so downstream SDK components observe the // canonical id/role instead of the placeholder used at builder time (AND-1202). - userRepository.setUser(response.user.toUser().copy(type = UserType.Guest)) + userRepository.setUser(adoptServerUser(response.user.toUser())) + // Sync the JWT into the legacy REST auth path before core proceeds to WS auth. + onTokenIssued(response.accessToken) return StreamToken.fromString(response.accessToken) } + + /** + * Ports iOS's name-preservation quirk (`StreamVideo.loadGuestUserInfo`): the server decorates + * guest display names (e.g. `guest--`); when the decorated name's last `-` component + * equals the locally supplied name, keep the local name. + */ + private fun adoptServerUser(serverUser: User): User { + val localName = initialUser.name + val lastNameComponent = serverUser.name?.split("-")?.lastOrNull() + val resolvedName = if (!localName.isNullOrBlank() && lastNameComponent == localName) { + localName + } else { + serverUser.name + } + return serverUser.copy(type = UserType.Guest, name = resolvedName) + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/BindableWritableUserRepositoryTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/BindableWritableUserRepositoryTest.kt new file mode 100644 index 00000000000..0f4810dbb62 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/BindableWritableUserRepositoryTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.core + +import io.getstream.video.android.model.User +import io.getstream.video.android.model.UserType +import org.junit.Test +import kotlin.test.assertEquals + +internal class BindableWritableUserRepositoryTest { + + private val guest = User(id = "guest-1", type = UserType.Guest) + + @Test + fun `setUser after bind delivers immediately`() { + val repo = BindableWritableUserRepository() + val delivered = mutableListOf() + repo.bind(delivered::add) + + repo.setUser(guest) + + assertEquals(listOf(guest), delivered) + } + + @Test + fun `setUser before bind is buffered and delivered on bind`() { + val repo = BindableWritableUserRepository() + val delivered = mutableListOf() + + repo.setUser(guest) + repo.bind(delivered::add) + + assertEquals(listOf(guest), delivered) + } + + @Test + fun `only the latest pre-bind user is delivered`() { + val repo = BindableWritableUserRepository() + val delivered = mutableListOf() + val stale = guest.copy(name = "stale") + val fresh = guest.copy(name = "fresh") + + repo.setUser(stale) + repo.setUser(fresh) + repo.bind(delivered::add) + + assertEquals(listOf(fresh), delivered) + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoBuilderDispatchTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoBuilderDispatchTest.kt index 145b71bcc5b..de30cef1517 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoBuilderDispatchTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/StreamVideoBuilderDispatchTest.kt @@ -19,6 +19,7 @@ package io.getstream.video.android.core import android.content.Context import android.net.ConnectivityManager import io.getstream.android.core.api.StreamClient +import io.getstream.video.android.core.socket.coordinator.v2.AnonymousStreamTokenProvider import io.getstream.video.android.core.socket.coordinator.v2.GuestStreamTokenProvider import io.getstream.video.android.core.socket.coordinator.v2.IntegrationStreamTokenProvider import io.getstream.video.android.model.User @@ -27,7 +28,6 @@ import io.mockk.every import io.mockk.mockk import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFailsWith import kotlin.test.assertTrue /** @@ -40,15 +40,16 @@ import kotlin.test.assertTrue internal class StreamVideoBuilderDispatchTest { @Test - fun `Anonymous user throws IllegalStateException with D-07 reference`() { - val builder = builderFor( + fun `Anonymous path selects AnonymousStreamTokenProvider and does not throw`() { + // iOS parity: anonymous users construct normally (REST-only, D-07); only explicit + // connect attempts fail. + val capturedProvider = captureFactoryArgsFor( user = User(id = "anon-1", type = UserType.Anonymous), - ) - - val err = assertFailsWith { builder.build() } + token = "", + )?.tokenProvider assertTrue( - err.message?.contains("D-07") == true, - "Expected IllegalStateException to reference D-07, got: ${err.message}", + capturedProvider is AnonymousStreamTokenProvider, + "Expected AnonymousStreamTokenProvider, got: ${capturedProvider?.javaClass}", ) } 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 a49edbeaa4d..0c3a08dc8ae 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 @@ -25,6 +25,7 @@ import io.getstream.android.video.generated.models.CallAcceptedEvent import io.getstream.android.video.generated.models.CallRingEvent import io.getstream.android.video.generated.models.CallSessionStartedEvent import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.result.Result.Failure import io.getstream.result.Result.Success import io.getstream.video.android.core.call.CallBusyHandler import io.getstream.video.android.core.call.RtcSession @@ -34,6 +35,8 @@ import io.getstream.video.android.core.notifications.internal.StreamNotification 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.getstream.video.android.model.UserType import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -81,7 +84,7 @@ class StreamVideoClientTest { val tokenRepository: TokenRepository, ) - private fun prepareClient(): ClientHarness { + private fun prepareClient(user: User = mockk(relaxed = true)): ClientHarness { val context = mockk(relaxed = true) val lifecycle = mockk(relaxed = true) val coordinator = mockk(relaxed = true) @@ -97,7 +100,7 @@ class StreamVideoClientTest { val client = spyk( StreamVideoClient( context = context, - user = mockk(relaxed = true), + user = user, apiKey = "apikey", token = "token", lifecycle = lifecycle, @@ -319,4 +322,29 @@ class StreamVideoClientTest { // assertion guards against a future regression that reintroduces the coupling.) verify(exactly = 0) { harness.tokenRepository.updateToken(any()) } } + + @Test + fun `connectAsync fails fast for anonymous users without touching streamClient`() = runTest { + // D-07 / iOS parity: anonymous users are REST-only; connect attempts fail before + // reaching the network. + val harness = prepareClient( + user = User(id = "anon-1", type = UserType.Anonymous), + ) + + val result = harness.client.connectAsync().await() + + assertTrue(result is Failure) + coVerify(exactly = 0) { harness.streamClient.connect() } + } + + @Test + fun `connectIfNotAlreadyConnected is a no-op for anonymous users`() = runTest { + val harness = prepareClient( + user = User(id = "anon-1", type = UserType.Anonymous), + ) + + harness.client.connectIfNotAlreadyConnected() + + coVerify(exactly = 0) { harness.streamClient.connect() } + } } diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProviderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProviderTest.kt index 4a2abb579ea..d3769bc7bc8 100644 --- a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProviderTest.kt +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProviderTest.kt @@ -51,7 +51,7 @@ internal class GuestStreamTokenProviderTest { duration = "10ms", user = serverIssuedUser(), ) - val subject = GuestStreamTokenProvider(api, initialUser, repo) + val subject = GuestStreamTokenProvider(api, initialUser, repo, onTokenIssued = {}) val token = subject.loadToken(StreamUserId.fromString("guest-1")) @@ -66,6 +66,65 @@ internal class GuestStreamTokenProviderTest { } } + @Test + fun `loadToken publishes the issued token to onTokenIssued before returning`() = runTest { + val api = mockk() + val repo = mockk(relaxed = true) + coEvery { api.createGuest(any()) } returns CreateGuestResponse( + accessToken = "fake-jwt", + duration = "10ms", + user = serverIssuedUser(), + ) + val issuedTokens = mutableListOf() + val subject = GuestStreamTokenProvider( + api = api, + initialUser = User(id = "guest-local", type = UserType.Guest), + userRepository = repo, + onTokenIssued = issuedTokens::add, + ) + + val token = subject.loadToken(StreamUserId.fromString("guest-1")) + + // COORD-03 split-brain invariant: the REST auth path (legacy CoordinatorAuthInterceptor) + // must observe the same JWT that core's StreamTokenManager receives. + assertEquals(listOf("fake-jwt"), issuedTokens) + assertEquals("fake-jwt", token.rawValue) + } + + @Test + fun `adopted user keeps local display name when server decorates it (iOS parity)`() = runTest { + val api = mockk() + val repo = mockk(relaxed = true) + val initialUser = User(id = "guest-local", name = "Alex", type = UserType.Guest) + coEvery { api.createGuest(any()) } returns CreateGuestResponse( + accessToken = "fake-jwt", + duration = "10ms", + user = serverIssuedUser(name = "guest-abc123-Alex"), + ) + val subject = GuestStreamTokenProvider(api, initialUser, repo, onTokenIssued = {}) + + subject.loadToken(StreamUserId.fromString("guest-1")) + + verify(exactly = 1) { repo.setUser(match { it.name == "Alex" }) } + } + + @Test + fun `adopted user keeps server name when it does not derive from the local name`() = runTest { + val api = mockk() + val repo = mockk(relaxed = true) + val initialUser = User(id = "guest-local", name = "Alex", type = UserType.Guest) + coEvery { api.createGuest(any()) } returns CreateGuestResponse( + accessToken = "fake-jwt", + duration = "10ms", + user = serverIssuedUser(name = "Server Chosen Name"), + ) + val subject = GuestStreamTokenProvider(api, initialUser, repo, onTokenIssued = {}) + + subject.loadToken(StreamUserId.fromString("guest-1")) + + verify(exactly = 1) { repo.setUser(match { it.name == "Server Chosen Name" }) } + } + @Test fun `UserRequest carries initial user fields`() = runTest { val api = mockk() @@ -83,7 +142,7 @@ internal class GuestStreamTokenProviderTest { duration = "5ms", user = serverIssuedUser(), ) - val subject = GuestStreamTokenProvider(api, initialUser, repo) + val subject = GuestStreamTokenProvider(api, initialUser, repo, onTokenIssued = {}) subject.loadToken(StreamUserId.fromString("guest-1")) @@ -93,12 +152,13 @@ internal class GuestStreamTokenProviderTest { assertEquals("en_US", captured.captured.user.custom?.get("locale")) } - private fun serverIssuedUser(): UserResponse = UserResponse( + private fun serverIssuedUser(name: String? = null): UserResponse = UserResponse( createdAt = FIXED_TIME, id = "server-issued-id", language = "en", role = "user", updatedAt = FIXED_TIME, + name = name, ) private companion object { From 97f40031bd7ce19a424120c02ac8d958e1f7a507 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Tue, 7 Jul 2026 15:26:00 +0200 Subject: [PATCH 7/7] build(test): set 2g heap for core unit-test executor Forked test JVMs do not inherit org.gradle.jvmargs and run on Gradle's 512m default. The suite (Robolectric framework sandbox, MockK inline instrumentation, OkHttp mock servers in a single reused executor) now exceeds that on CI: the executor died with OutOfMemoryError across OkHttp/Robolectric/test-worker threads. The earlier intermittent 'Can't instantiate proxy' MockK failures were the same memory pressure surfacing sooner. --- stream-video-android-core/build.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/stream-video-android-core/build.gradle.kts b/stream-video-android-core/build.gradle.kts index d3998fa6462..a614927485a 100644 --- a/stream-video-android-core/build.gradle.kts +++ b/stream-video-android-core/build.gradle.kts @@ -86,6 +86,12 @@ android { unitTests { isIncludeAndroidResources = true isReturnDefaultValues = true + all { test -> + // Forked test-executor JVMs do not inherit org.gradle.jvmargs and default to + // 512m, which the suite (MockK inline instrumentation + Robolectric + mock + // web servers) now exceeds on CI runners. + test.maxHeapSize = "2g" + } } managedDevices {