From dc94f8f7dd28196527279ce58a634124844c3e91 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:05:03 +0200 Subject: [PATCH] Move search, members and unread counts to the v2 endpoints --- .../chat/android/client/api2/MoshiChatApi.kt | 6 +- .../client/api2/endpoint/GeneralApi.kt | 22 +-- .../android/client/api2/endpoint/SyncApi.kt | 45 ++++++ .../chat/android/client/di/ChatModule.kt | 4 +- .../android/client/api2/MoshiChatApiTest.kt | 15 +- .../infrastructure/V2LaneBatchSixTest.kt | 137 ++++++++++++++++++ 6 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/SyncApi.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/network/infrastructure/V2LaneBatchSixTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt index 92d0593c4825..9fad1c4e2cb6 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt @@ -40,6 +40,7 @@ import io.getstream.chat.android.client.api2.endpoint.PollsApi import io.getstream.chat.android.client.api2.endpoint.PushPreferencesApi import io.getstream.chat.android.client.api2.endpoint.RemindersApi import io.getstream.chat.android.client.api2.endpoint.RoleApi +import io.getstream.chat.android.client.api2.endpoint.SyncApi import io.getstream.chat.android.client.api2.endpoint.ThreadsApi import io.getstream.chat.android.client.api2.endpoint.UserApi import io.getstream.chat.android.client.api2.endpoint.UserGroupApi @@ -215,6 +216,7 @@ constructor( private val deviceApi: DeviceApi, private val moderationApi: ModerationApi, private val generalApi: GeneralApi, + private val syncApi: SyncApi, private val configApi: ConfigApi, private val fileDownloadApi: FileDownloadApi, private val ogApi: OpenGraphApi, @@ -1679,7 +1681,7 @@ constructor( override fun getSyncHistory(channelIds: List, lastSyncAt: String): Call> = with(eventMapping) { - return generalApi.getSyncHistory( + return syncApi.getSyncHistory( body = SyncHistoryRequest(channelIds, lastSyncAt), connectionId = connectionId, ).map { response -> @@ -2009,7 +2011,7 @@ constructor( } override fun warmUp() { - generalApi.warmUp().enqueue() + syncApi.warmUp().enqueue() } private fun postponeCall(call: () -> Call): Call { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/GeneralApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/GeneralApi.kt index d15a7662e654..a1f6810c498d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/GeneralApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/GeneralApi.kt @@ -17,44 +17,28 @@ package io.getstream.chat.android.client.api2.endpoint import io.getstream.chat.android.client.api.AuthenticatedApi -import io.getstream.chat.android.client.api.QueryParams import io.getstream.chat.android.client.api2.UrlQueryPayload -import io.getstream.chat.android.client.api2.model.requests.SyncHistoryRequest -import io.getstream.chat.android.client.api2.model.response.SyncHistoryResponse import io.getstream.chat.android.client.call.RetrofitCall import io.getstream.chat.android.network.models.MembersResponse import io.getstream.chat.android.network.models.QueryMembersPayload import io.getstream.chat.android.network.models.SearchPayload import io.getstream.chat.android.network.models.SearchResponse import io.getstream.chat.android.network.models.WrappedUnreadCountsResponse -import okhttp3.ResponseBody -import retrofit2.http.Body import retrofit2.http.GET -import retrofit2.http.OPTIONS -import retrofit2.http.POST import retrofit2.http.Query @AuthenticatedApi internal interface GeneralApi { - @OPTIONS("/connect") - fun warmUp(): RetrofitCall - - @POST("/sync") - fun getSyncHistory( - @Body body: SyncHistoryRequest, - @Query(QueryParams.CONNECTION_ID) connectionId: String, - ): RetrofitCall - - @GET("/search") + @GET("/api/v2/chat/search") fun searchMessages( @UrlQueryPayload @Query("payload") payload: SearchPayload, ): RetrofitCall - @GET("/members") + @GET("/api/v2/chat/members") fun queryMembers( @UrlQueryPayload @Query("payload") payload: QueryMembersPayload, ): RetrofitCall - @GET("/unread") + @GET("/api/v2/chat/unread") fun getUnreadCounts(): RetrofitCall } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/SyncApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/SyncApi.kt new file mode 100644 index 000000000000..671a82aace98 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/SyncApi.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-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.chat.android.client.api2.endpoint + +import io.getstream.chat.android.client.api.AuthenticatedApi +import io.getstream.chat.android.client.api.QueryParams +import io.getstream.chat.android.client.api2.model.requests.SyncHistoryRequest +import io.getstream.chat.android.client.api2.model.response.SyncHistoryResponse +import io.getstream.chat.android.client.call.RetrofitCall +import okhttp3.ResponseBody +import retrofit2.http.Body +import retrofit2.http.OPTIONS +import retrofit2.http.POST +import retrofit2.http.Query + +/** + * Endpoints tied to the websocket connection, which stay on v1. `/sync` returns events in the socket's encoding, so it + * moves together with the socket; the `/connect` warm-up has no v2 route (the v2 socket is `/api/v2/connect`). + */ +@AuthenticatedApi +internal interface SyncApi { + + @OPTIONS("/connect") + fun warmUp(): RetrofitCall + + @POST("/sync") + fun getSyncHistory( + @Body body: SyncHistoryRequest, + @Query(QueryParams.CONNECTION_ID) connectionId: String, + ): RetrofitCall +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt index 76fca7434615..07fece3d8498 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/di/ChatModule.kt @@ -53,6 +53,7 @@ import io.getstream.chat.android.client.api2.endpoint.PollsApi import io.getstream.chat.android.client.api2.endpoint.PushPreferencesApi import io.getstream.chat.android.client.api2.endpoint.RemindersApi import io.getstream.chat.android.client.api2.endpoint.RoleApi +import io.getstream.chat.android.client.api2.endpoint.SyncApi import io.getstream.chat.android.client.api2.endpoint.ThreadsApi import io.getstream.chat.android.client.api2.endpoint.UserApi import io.getstream.chat.android.client.api2.endpoint.UserGroupApi @@ -351,7 +352,8 @@ constructor( buildRetrofitApi(), buildV2RetrofitApi(), buildRetrofitApi(), - buildRetrofitApi(), + buildV2RetrofitApi(), + buildRetrofitApi(), buildV2RetrofitApi(), buildFileDownloadApi(), buildV2RetrofitApi(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt index be9ae2f79538..329676dcc7b6 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.client.api2.endpoint.PollsApi import io.getstream.chat.android.client.api2.endpoint.PushPreferencesApi import io.getstream.chat.android.client.api2.endpoint.RemindersApi import io.getstream.chat.android.client.api2.endpoint.RoleApi +import io.getstream.chat.android.client.api2.endpoint.SyncApi import io.getstream.chat.android.client.api2.endpoint.ThreadsApi import io.getstream.chat.android.client.api2.endpoint.UserApi import io.getstream.chat.android.client.api2.endpoint.UserGroupApi @@ -2650,10 +2651,10 @@ internal class MoshiChatApiTest { @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#getSyncHistoryInput") fun testGetSyncHistory(call: RetrofitCall, expected: KClass<*>) = runTest { // given - val api = mock() + val api = mock() whenever(api.getSyncHistory(any(), any())).doReturn(call) val sut = Fixture() - .withGeneralApi(api) + .withSyncApi(api) .get() // when val userId = randomString() @@ -3502,10 +3503,10 @@ internal class MoshiChatApiTest { @Test fun testWarmUp() = runTest { // given - val api = mock() + val api = mock() whenever(api.warmUp()).doReturn(RetroSuccess(randomString().toResponseBody()).toRetrofitCall()) val sut = Fixture() - .withGeneralApi(api) + .withSyncApi(api) .get() // when sut.warmUp() @@ -3762,6 +3763,7 @@ internal class MoshiChatApiTest { private var deviceApi: DeviceApi = mock() private var moderationApi: ModerationApi = mock() private var generalApi: GeneralApi = mock() + private var syncApi: SyncApi = mock() private var configApi: ConfigApi = mock() private var fileDownloadApi: FileDownloadApi = mock() private var ogApi: OpenGraphApi = mock() @@ -3807,6 +3809,10 @@ internal class MoshiChatApiTest { this.generalApi = generalApi } + fun withSyncApi(syncApi: SyncApi) = apply { + this.syncApi = syncApi + } + fun withConfigApi(configApi: ConfigApi) = apply { this.configApi = configApi } @@ -3871,6 +3877,7 @@ internal class MoshiChatApiTest { deviceApi = deviceApi, moderationApi = moderationApi, generalApi = generalApi, + syncApi = syncApi, configApi = configApi, fileDownloadApi = fileDownloadApi, ogApi = ogApi, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/network/infrastructure/V2LaneBatchSixTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/network/infrastructure/V2LaneBatchSixTest.kt new file mode 100644 index 000000000000..906a9928cb9f --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/network/infrastructure/V2LaneBatchSixTest.kt @@ -0,0 +1,137 @@ +/* + * 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-chat-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.chat.android.network.infrastructure + +import io.getstream.chat.android.client.api2.mapping.DomainMapping +import io.getstream.chat.android.client.parser2.ParserFactory +import io.getstream.chat.android.models.NoOpChannelTransformer +import io.getstream.chat.android.models.NoOpMessageTransformer +import io.getstream.chat.android.models.NoOpUserTransformer +import io.getstream.chat.android.network.models.ChannelMemberRequest +import io.getstream.chat.android.network.models.MembersResponse +import io.getstream.chat.android.network.models.QueryMembersPayload +import io.getstream.chat.android.network.models.SearchPayload +import io.getstream.chat.android.network.models.SearchResponse +import io.getstream.chat.android.network.models.SortParamRequest +import io.getstream.chat.android.network.models.WrappedUnreadCountsResponse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.util.Date + +/** + * Search, members and unread counts parse their recorded v2 responses through the generated [Serializer] alone. Their + * options travel as the JSON `payload` query parameter on both versions, with the same content except the custom data + * of the members in a members query, which v2 keeps nested. + */ +internal class V2LaneBatchSixTest { + + private val v1Parser = ParserFactory.createMoshiChatParser() + private val mapping = DomainMapping( + currentUserIdProvider = { "filip" }, + channelTransformer = NoOpChannelTransformer, + messageTransformer = NoOpMessageTransformer, + userTransformer = NoOpUserTransformer, + ) + + private inline fun parse(json: String): T = Serializer.moshi.adapter(T::class.java).fromJson(json)!! + + private inline fun v2Body(request: T): String = Serializer.moshi.adapter(T::class.java).toJson(request) + + @Test + fun `A search result parses its nested custom data and integer dates from v2`() { + val message = with(mapping) { parse(SEARCH).results.single().message!!.toDomain() } + + assertEquals(mapOf("s_probe" to "nested"), message.extraData) + assertEquals(Date(1791798765916), message.createdAt) + } + + @Test + fun `A member parses its user's nested custom data from v2`() { + val member = with(mapping) { parse(MEMBERS).members.single().toDomain() } + + assertEquals(mapOf("birthland" to "Tatooine"), member.user.extraData) + assertEquals(Date(1634854188381), member.createdAt) + } + + @Test + fun `Unread counts parse their integer dates from v2`() { + val counts = with(mapping) { parse(UNREAD).toDomain() } + + assertEquals(2, counts.threadsCount) + assertEquals(Date(1789047633686), counts.threads.single().lastRead) + } + + @Test + fun `Search and members payloads are the same on v2 as on v1`() { + val sort = listOf(SortParamRequest(field = "created_at", direction = -1)) + val since = mapOf("created_at" to mapOf("\$gt" to Date(1791798765916))) + assertSameBody( + SearchPayload( + filterConditions = mapOf("cid" to "messaging:sample-app-channel-8"), + messageFilterConditions = since, + query = "zebra", + limit = 2, + sort = sort, + ), + ) + assertSameBody( + QueryMembersPayload(type = "messaging", id = "a", filterConditions = since, limit = 2, sort = sort), + ) + } + + @Test + fun `A members query nests the members' custom data on v2`() { + val payload = QueryMembersPayload( + type = "messaging", + members = listOf(ChannelMemberRequest(userId = "amit", custom = mapOf("k" to "v"))), + ) + + assertEquals( + """{"type":"messaging","filter_conditions":{},"members":[{"user_id":"amit","custom":{"k":"v"}}],"sort":[]}""", + v2Body(payload), + ) + } + + private inline fun assertSameBody(request: T) { + assertEquals(v1Parser.toJson(request), v2Body(request)) + } + + private companion object { + private const val USER = """{"id":"filip","name":"Chewbacca","custom":{},"language":"","role":"user", + "teams":[],"created_at":1634853490403855000,"updated_at":1791791284528654000,"banned":false, + "online":true,"blocked_user_ids":[]}""" + + private const val SEARCH = """{"results":[{"message":{"id":"fc511a6e-2e8e-4d2c-b0a8-46b52266c39b", + "text":"v2capsearch zebra","html":"

v2capsearch zebra

\n","type":"regular","user":$USER, + "attachments":[],"latest_reactions":[],"own_reactions":[],"reaction_counts":{},"reaction_scores":{}, + "reply_count":0,"deleted_reply_count":0,"cid":"messaging:sample-app-channel-8", + "created_at":1791798765916694000,"updated_at":1791798765916694000,"custom":{"s_probe":"nested"}, + "shadowed":false,"mentioned_users":[],"mentioned_channel":false,"mentioned_here":false,"silent":false, + "pinned":false,"restricted_visibility":[]}}],"duration":"45.99ms"}""" + + private const val MEMBERS = """{"members":[{"user_id":"amit","user":{"id":"amit","name":"Leia Organa", + "custom":{"birthland":"Tatooine"},"language":"hi","role":"user","teams":[], + "created_at":1634853490411137000,"updated_at":1788527535982343000,"banned":false,"online":false, + "blocked_user_ids":[]},"custom":{},"created_at":1634854188381661000,"updated_at":1786086894668787000, + "banned":false,"shadow_banned":false,"role":"member","channel_role":"channel_member", + "notifications_muted":false}],"duration":"9.60ms"}""" + + private const val UNREAD = """{"total_unread_count":0,"total_unread_threads_count":2,"channels":[], + "channel_type":[],"threads":[{"unread_count":1,"last_read":1789047633686986000,"last_read_message_id":"", + "parent_message_id":"2b1bbee3-eeb9-434c-ab78-9765abecc357"}],"duration":"7.50ms"}""" + } +}