From 66eeb4c5f9d235c005107ea14f6afaf553237386 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 1 Jul 2026 09:23:53 +0200 Subject: [PATCH 1/4] feat(coord-v2): add core v5 token and serialization adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VideoEventSerialization wraps MoshiVideoParser as StreamEventSerialization - IntegrationStreamTokenProvider adapts video TokenProvider to core StreamTokenProvider - GuestStreamTokenProvider mints a guest JWT via createGuest and adopts server identity - ConnectionStateMapper publishes StreamConnectionState -> ConnectionState extension (placeholder mapping — a later plan reshapes ConnectionState to mirror core 1:1) --- .../coordinator/v2/ConnectionStateMapper.kt | 53 ++++++++++++++ .../v2/GuestStreamTokenProvider.kt | 72 +++++++++++++++++++ .../v2/IntegrationStreamTokenProvider.kt | 44 ++++++++++++ .../coordinator/v2/VideoEventSerialization.kt | 42 +++++++++++ 4 files changed, 211 insertions(+) create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/ConnectionStateMapper.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProvider.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/IntegrationStreamTokenProvider.kt create mode 100644 stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/VideoEventSerialization.kt diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/ConnectionStateMapper.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/ConnectionStateMapper.kt new file mode 100644 index 00000000000..57307bd07bf --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/ConnectionStateMapper.kt @@ -0,0 +1,53 @@ +/* + * 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.model.connection.StreamConnectionState +import io.getstream.result.Error +import io.getstream.video.android.core.ConnectionState + +/** + * Maps a core [StreamConnectionState] onto the SDK's existing [ConnectionState] shape. + * + * The mapping produced here is a placeholder that preserves compilation until a later plan + * reshapes the video [ConnectionState] sealed interface to mirror the richer core states 1:1 + * (D-04, D-09). Once that reshape lands, both this function body and the collaborating tests + * flip together in a single commit. + */ +internal fun StreamConnectionState.toVideoConnectionState(): ConnectionState { + // TODO(02-03): replace placeholder mapping when ConnectionState sealed interface is + // rewritten per D-04/D-09. + return when (this) { + is StreamConnectionState.Idle -> ConnectionState.PreConnect + is StreamConnectionState.Connecting.Opening -> ConnectionState.Loading + is StreamConnectionState.Connecting.Authenticating -> ConnectionState.Loading + is StreamConnectionState.Connected -> ConnectionState.Connected + is StreamConnectionState.Disconnected -> { + val throwable = cause + if (throwable == null) { + ConnectionState.Disconnected + } else { + ConnectionState.Failed( + Error.ThrowableError( + message = throwable.message ?: "", + cause = throwable, + ), + ) + } + } + } +} 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 new file mode 100644 index 00000000000..ac5c41bd20e --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProvider.kt @@ -0,0 +1,72 @@ +/* + * 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 +import io.getstream.android.video.generated.apis.ProductvideoApi +import io.getstream.android.video.generated.models.CreateGuestRequest +import io.getstream.android.video.generated.models.UserRequest +import io.getstream.video.android.core.utils.toUser +import io.getstream.video.android.model.User +import io.getstream.video.android.model.UserType + +/** + * Writable sink for the SDK's currently active [User]. Introduced here as a minimal seam so the + * guest flow can adopt the server-issued identity (id, role, image, custom) returned by the + * `createGuest` REST call. Plan 02-02 will connect this to the concrete SDK-wide user store. + */ +internal interface WritableUserRepository { + /** Persists [user] as the SDK's active user. */ + fun setUser(user: User) +} + +/** + * [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]. + * + * `StreamTokenManager` wraps this provider in a `StreamSingleFlightProcessor`, so concurrent + * callers share the same in-flight request. No additional deduplication is needed here (unlike + * the legacy `guestUserJob: Deferred` synchronization layer that this replaces). + */ +internal class GuestStreamTokenProvider( + private val api: ProductvideoApi, + private val initialUser: User, + private val userRepository: WritableUserRepository, +) : StreamTokenProvider { + + override suspend fun loadToken(userId: StreamUserId): StreamToken { + val response = api.createGuest( + createGuestRequest = CreateGuestRequest( + user = UserRequest( + id = initialUser.id, + image = initialUser.image, + name = initialUser.name, + custom = initialUser.custom, + ), + ), + ) + // 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)) + return StreamToken.fromString(response.accessToken) + } +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/IntegrationStreamTokenProvider.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/IntegrationStreamTokenProvider.kt new file mode 100644 index 00000000000..5fceb417774 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/IntegrationStreamTokenProvider.kt @@ -0,0 +1,44 @@ +/* + * 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 +import io.getstream.video.android.core.socket.common.token.TokenProvider + +/** + * Adapts the integration-supplied video [TokenProvider] to core's [StreamTokenProvider]. + * + * Selected at builder time for authenticated users; guest users are served by + * `GuestStreamTokenProvider` instead. See D-05 in the phase context. + */ +internal class IntegrationStreamTokenProvider( + private val delegate: TokenProvider, +) : StreamTokenProvider { + + /** + * Loads a token from the wrapped integration [TokenProvider]. + * + * The [userId] parameter is intentionally unused — the integration's + * `TokenProvider.loadToken()` already binds the user identity through the SDK's builder + * configuration (D-05). Core passes the identifier for parity with alternate providers + * that mint per-user tokens. + */ + override suspend fun loadToken(userId: StreamUserId): StreamToken = + StreamToken.fromString(delegate.loadToken()) +} diff --git a/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/VideoEventSerialization.kt b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/VideoEventSerialization.kt new file mode 100644 index 00000000000..d17a1b4f3c7 --- /dev/null +++ b/stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/socket/coordinator/v2/VideoEventSerialization.kt @@ -0,0 +1,42 @@ +/* + * 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.serialization.StreamEventSerialization +import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.video.android.core.socket.common.parser2.MoshiVideoParser + +/** + * Adapts the existing Moshi-based [MoshiVideoParser] to core's [StreamEventSerialization] + * interface so that `stream-android-core`'s composite deserializer can route product events + * to the video SDK without re-implementing Moshi adapter registration. + * + * Wraps parser calls in [runCatching] because [MoshiVideoParser.fromJson] throws on null while + * the contract here is a [Result]. + */ +internal class VideoEventSerialization( + private val parser: MoshiVideoParser = MoshiVideoParser(), +) : StreamEventSerialization { + + override fun serialize(data: VideoEvent): Result = runCatching { + parser.toJson(data) + } + + override fun deserialize(raw: String): Result = runCatching { + parser.fromJson(raw, VideoEvent::class.java) + } +} From 625bc87ed3682a9cc09db0f9912d64b06a236cc3 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Wed, 1 Jul 2026 09:31:49 +0200 Subject: [PATCH 2/4] test(coord-v2): add wave 0 adapter and connection-state tests - VideoEventSerializationTest round-trips four VideoEvent subclasses through MoshiVideoParser plus a malformed-input negative case - IntegrationStreamTokenProviderTest asserts delegation and userId being ignored - GuestStreamTokenProviderTest verifies createGuest single-call and user adoption - ClientStateConnectionMappingTest is table-driven over StreamConnectionState variants; each expected value carries a TODO(02-03) handoff marker --- .../core/ClientStateConnectionMappingTest.kt | 96 +++++++++++ .../v2/GuestStreamTokenProviderTest.kt | 108 +++++++++++++ .../v2/IntegrationStreamTokenProviderTest.kt | 54 +++++++ .../v2/VideoEventSerializationTest.kt | 150 ++++++++++++++++++ 4 files changed, 408 insertions(+) create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ClientStateConnectionMappingTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProviderTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/IntegrationStreamTokenProviderTest.kt create mode 100644 stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/VideoEventSerializationTest.kt diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ClientStateConnectionMappingTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ClientStateConnectionMappingTest.kt new file mode 100644 index 00000000000..0b63fbe69dc --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/ClientStateConnectionMappingTest.kt @@ -0,0 +1,96 @@ +/* + * 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.android.core.api.model.connection.StreamConnectedUser +import io.getstream.android.core.api.model.connection.StreamConnectionState +import io.getstream.video.android.core.socket.coordinator.v2.toVideoConnectionState +import org.junit.Test +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Table-driven mapping test for [StreamConnectionState] -> [ConnectionState] extension. + * + * Asserts the temporary placeholder mapping shipped by Plan 02-01. A subsequent plan reshapes + * the video [ConnectionState] sealed interface to mirror core 1:1; both this test and the + * mapper body flip together in that plan. + */ +internal class ClientStateConnectionMappingTest { + + @Test + fun `Idle maps to PreConnect`() { + val result = (StreamConnectionState.Idle as StreamConnectionState).toVideoConnectionState() + assertEquals(ConnectionState.PreConnect, result) + // TODO(02-03): update expected value when ConnectionState sealed interface is rewritten + } + + @Test + fun `Connecting Opening maps to Loading`() { + val result: ConnectionState = + StreamConnectionState.Connecting.Opening("user-1").toVideoConnectionState() + assertEquals(ConnectionState.Loading, result) + // TODO(02-03): update expected value when ConnectionState sealed interface is rewritten + } + + @Test + fun `Connecting Authenticating maps to Loading`() { + val result: ConnectionState = + StreamConnectionState.Connecting.Authenticating("user-1").toVideoConnectionState() + assertEquals(ConnectionState.Loading, result) + // TODO(02-03): update expected value when ConnectionState sealed interface is rewritten + } + + @Test + fun `Connected maps to Connected`() { + val result: ConnectionState = StreamConnectionState + .Connected(connectedUserFixture(), "connection-id") + .toVideoConnectionState() + assertEquals(ConnectionState.Connected, result) + // TODO(02-03): update expected value when ConnectionState sealed interface is rewritten + } + + @Test + fun `Disconnected with null cause maps to Disconnected`() { + val result: ConnectionState = + StreamConnectionState.Disconnected(cause = null).toVideoConnectionState() + assertEquals(ConnectionState.Disconnected, result) + // TODO(02-03): update expected value when ConnectionState sealed interface is rewritten + } + + @Test + fun `Disconnected with cause maps to Failed`() { + val boom = IllegalStateException("boom") + val result: ConnectionState = + StreamConnectionState.Disconnected(cause = boom).toVideoConnectionState() + + assertTrue(result is ConnectionState.Failed) + assertEquals("boom", result.error.message) + // TODO(02-03): update expected value when ConnectionState sealed interface is rewritten + } + + private fun connectedUserFixture(): StreamConnectedUser = StreamConnectedUser( + createdAt = Date(0), + id = "user-1", + language = "en", + role = "user", + updatedAt = Date(0), + blockedUserIds = emptyList(), + teams = emptyList(), + ) +} 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 new file mode 100644 index 00000000000..4a2abb579ea --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/GuestStreamTokenProviderTest.kt @@ -0,0 +1,108 @@ +/* + * 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.model.value.StreamUserId +import io.getstream.android.video.generated.apis.ProductvideoApi +import io.getstream.android.video.generated.models.CreateGuestRequest +import io.getstream.android.video.generated.models.CreateGuestResponse +import io.getstream.android.video.generated.models.UserResponse +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.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.threeten.bp.OffsetDateTime +import kotlin.test.assertEquals + +internal class GuestStreamTokenProviderTest { + + @Test + fun `loadToken calls createGuest once and adopts server-issued user`() = runTest { + val api = mockk() + val repo = mockk(relaxed = true) + val initialUser = User( + id = "guest-local", + image = "https://example.com/avatar.png", + name = "Guest Local", + custom = mapOf("k" to "v"), + type = UserType.Guest, + ) + coEvery { api.createGuest(any()) } returns CreateGuestResponse( + accessToken = "fake-jwt", + duration = "10ms", + user = serverIssuedUser(), + ) + val subject = GuestStreamTokenProvider(api, initialUser, repo) + + val token = subject.loadToken(StreamUserId.fromString("guest-1")) + + coVerify(exactly = 1) { api.createGuest(any()) } + assertEquals("fake-jwt", token.rawValue) + verify(exactly = 1) { + repo.setUser( + match { user -> + user.id == "server-issued-id" && user.type == UserType.Guest + }, + ) + } + } + + @Test + fun `UserRequest carries initial user fields`() = runTest { + val api = mockk() + val repo = mockk(relaxed = true) + val initialUser = User( + id = "guest-local", + image = "https://example.com/pic.png", + name = "Local Name", + custom = mapOf("locale" to "en_US"), + type = UserType.Guest, + ) + val captured = slot() + coEvery { api.createGuest(capture(captured)) } returns CreateGuestResponse( + accessToken = "fake-jwt", + duration = "5ms", + user = serverIssuedUser(), + ) + val subject = GuestStreamTokenProvider(api, initialUser, repo) + + subject.loadToken(StreamUserId.fromString("guest-1")) + + assertEquals("guest-local", captured.captured.user.id) + assertEquals("https://example.com/pic.png", captured.captured.user.image) + assertEquals("Local Name", captured.captured.user.name) + assertEquals("en_US", captured.captured.user.custom?.get("locale")) + } + + private fun serverIssuedUser(): UserResponse = UserResponse( + createdAt = FIXED_TIME, + id = "server-issued-id", + language = "en", + role = "user", + updatedAt = FIXED_TIME, + ) + + private companion object { + val FIXED_TIME: OffsetDateTime = + OffsetDateTime.parse("2026-01-01T00:00:00Z") + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/IntegrationStreamTokenProviderTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/IntegrationStreamTokenProviderTest.kt new file mode 100644 index 00000000000..3ffde4e0167 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/IntegrationStreamTokenProviderTest.kt @@ -0,0 +1,54 @@ +/* + * 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.model.value.StreamUserId +import io.getstream.video.android.core.socket.common.token.TokenProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +internal class IntegrationStreamTokenProviderTest { + + @Test + fun `loadToken delegates to wrapped TokenProvider`() = runTest { + val delegate = mockk() + coEvery { delegate.loadToken() } returns "fake-jwt" + val subject = IntegrationStreamTokenProvider(delegate) + + val token = subject.loadToken(StreamUserId.fromString("any")) + + coVerify(exactly = 1) { delegate.loadToken() } + assertEquals("fake-jwt", token.rawValue) + } + + @Test + fun `userId parameter is unused across invocations`() = runTest { + val delegate = mockk() + coEvery { delegate.loadToken() } returns "fake-jwt" + val subject = IntegrationStreamTokenProvider(delegate) + + subject.loadToken(StreamUserId.fromString("user-a")) + subject.loadToken(StreamUserId.fromString("user-b")) + + // Two invocations produce two identical delegate calls regardless of userId. + coVerify(exactly = 2) { delegate.loadToken() } + } +} diff --git a/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/VideoEventSerializationTest.kt b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/VideoEventSerializationTest.kt new file mode 100644 index 00000000000..b3e7521aa52 --- /dev/null +++ b/stream-video-android-core/src/test/kotlin/io/getstream/video/android/core/socket/coordinator/v2/VideoEventSerializationTest.kt @@ -0,0 +1,150 @@ +/* + * 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.video.generated.models.APIError +import io.getstream.android.video.generated.models.ConnectedEvent +import io.getstream.android.video.generated.models.ConnectionErrorEvent +import io.getstream.android.video.generated.models.CustomVideoEvent +import io.getstream.android.video.generated.models.HealthCheckEvent +import io.getstream.android.video.generated.models.OwnUserResponse +import io.getstream.android.video.generated.models.UserResponse +import io.getstream.android.video.generated.models.VideoEvent +import io.getstream.video.android.core.socket.common.parser2.MoshiVideoParser +import org.junit.Test +import org.threeten.bp.OffsetDateTime +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Round-trip coverage for [VideoEventSerialization]. + * + * The event set is chosen to exercise four distinct [VideoEvent] subclasses whose Moshi schema + * is self-contained (no nested `CallResponse` graph that would require a 15-level fixture). + * Together they validate the adapter's ability to route arbitrary product events through the + * existing [MoshiVideoParser]. + */ +internal class VideoEventSerializationTest { + + private val subject = VideoEventSerialization(MoshiVideoParser()) + + @Test + fun `round-trip ConnectedEvent`() { + val event = ConnectedEvent( + connectionId = "conn-1", + createdAt = FIXED_TIME, + me = ownUserResponseFixture(), + type = "connection.ok", + ) + + val json = subject.serialize(event).getOrThrow() + assertTrue(json.isNotBlank()) + assertTrue(json.contains("connection.ok")) + + val decoded = subject.deserialize(json).getOrThrow() + assertTrue(decoded is ConnectedEvent) + assertEquals("conn-1", (decoded as ConnectedEvent).connectionId) + } + + @Test + fun `round-trip CustomVideoEvent`() { + val event = CustomVideoEvent( + callCid = "default:call-custom", + createdAt = FIXED_TIME, + custom = mapOf("k" to "v"), + user = userResponseFixture(), + type = "custom", + ) + + val json = subject.serialize(event).getOrThrow() + val decoded = subject.deserialize(json).getOrThrow() + + assertTrue(decoded is CustomVideoEvent) + assertEquals("default:call-custom", (decoded as CustomVideoEvent).callCid) + } + + @Test + fun `round-trip ConnectionErrorEvent`() { + val event = ConnectionErrorEvent( + connectionId = "conn-err", + createdAt = FIXED_TIME, + error = APIError( + statusCode = 500, + code = 1, + duration = "10ms", + details = emptyList(), + message = "boom", + moreInfo = "", + exceptionFields = emptyMap(), + unrecoverable = false, + ), + type = "connection.error", + ) + + val json = subject.serialize(event).getOrThrow() + val decoded = subject.deserialize(json).getOrThrow() + + assertTrue(decoded is ConnectionErrorEvent) + assertEquals("conn-err", (decoded as ConnectionErrorEvent).connectionId) + } + + @Test + fun `round-trip HealthCheckEvent`() { + val event = HealthCheckEvent( + connectionId = "conn-health", + createdAt = FIXED_TIME, + custom = emptyMap(), + type = "health.check", + ) + + val json = subject.serialize(event).getOrThrow() + val decoded = subject.deserialize(json).getOrThrow() + + assertTrue(decoded is HealthCheckEvent) + assertEquals("conn-health", (decoded as HealthCheckEvent).connectionId) + } + + @Test + fun `deserialize malformed JSON fails gracefully`() { + val result = subject.deserialize("not json") + + assertFalse(result.isSuccess) + assertTrue(result.isFailure) + } + + private fun ownUserResponseFixture(): OwnUserResponse = OwnUserResponse( + createdAt = FIXED_TIME, + id = "user-1", + language = "en", + role = "user", + updatedAt = FIXED_TIME, + ) + + private fun userResponseFixture(): UserResponse = UserResponse( + createdAt = FIXED_TIME, + id = "user-2", + language = "en", + role = "user", + updatedAt = FIXED_TIME, + ) + + private companion object { + val FIXED_TIME: OffsetDateTime = + OffsetDateTime.parse("2026-01-01T00:00:00Z") + } +} From 42f6b6de7cfdb7aadc50350ab22326509a926526 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Tue, 7 Jul 2026 11:20:03 +0200 Subject: [PATCH 3/4] chore(deps): bump stream-android-core to 5.0.1 Picks up the lenient WS date parsing fix (stream-core-android#71, AND-1295) - core's internal Moshi now accepts the RFC3339 date strings the video coordinator sends in connection.ok, unblocking the coordinator handshake on-device. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2c64b14dbed..dde0a155251 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,7 +52,7 @@ streamWebRTC = "137.1.1" streamNoiseCancellation = "2.0.0" streamResult = "1.3.0" streamChat = "6.10.0" -streamCore = "5.0.0" +streamCore = "5.0.1" streamLog = "1.3.2" streamPush = "1.3.4" streamRenderscript = "0.0.1" From 633b493ebd9660bace22e482b753a1863c782ef3 Mon Sep 17 00:00:00 2001 From: Aleksandar Apostolov Date: Tue, 7 Jul 2026 18:27:27 +0200 Subject: [PATCH 4/4] test(e2e): wait for own-tile mic icon in assertUserMicrophone The toggle assert waits, but the participant-view mic icon was asserted with a bare isDisplayed(). The icon reflects the published audio track state and lags the local toggle by an SFU round-trip, so the assert raced it and testUserMicrophone failed on slower CI runs. --- .../getstream/video/android/robots/UserRobotCallAsserts.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt index 941c0ac98ad..e374073f02a 100644 --- a/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt +++ b/demo-app/src/androidTestE2etestingDebug/kotlin/io/getstream/video/android/robots/UserRobotCallAsserts.kt @@ -73,12 +73,14 @@ fun UserRobot.assertUserMicrophone(isEnabled: Boolean, videoCall: Boolean = true if (isEnabled) { assertTrue(CallPage.microphoneEnabledToggle.waitToAppear().isDisplayed()) if (videoCall) { - assertTrue(CallPage.ParticipantView.microphoneEnabledIcon.isDisplayed()) + // The own-tile mic icon reflects the published audio track state and lags the + // local toggle by an SFU round-trip — wait for it instead of asserting instantly. + assertTrue(CallPage.ParticipantView.microphoneEnabledIcon.waitToAppear().isDisplayed()) } } else { assertTrue(CallPage.microphoneDisabledToggle.waitToAppear().isDisplayed()) if (videoCall) { - assertTrue(CallPage.ParticipantView.microphoneDisabledIcon.isDisplayed()) + assertTrue(CallPage.ParticipantView.microphoneDisabledIcon.waitToAppear().isDisplayed()) } } return this