From c6507869bfb78a0e912419c830cb2dfe07585e98 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 14 Apr 2026 14:33:49 +0200 Subject: [PATCH 01/21] Add GroupedQueryChannels --- .../api/stream-chat-android-client.api | 2 + .../chat/android/client/ChatClient.kt | 19 +++ .../chat/android/client/api/ChatApi.kt | 13 ++ .../chat/android/client/api2/MoshiChatApi.kt | 32 +++++ .../client/api2/endpoint/ChannelApi.kt | 14 ++ .../requests/GroupedQueryChannelsRequest.kt | 33 +++++ .../response/GroupedQueryChannelsResponse.kt | 49 +++++++ .../ChatClientGroupedChannelsApiTests.kt | 87 ++++++++++++ .../android/client/api2/MoshiChatApiTest.kt | 30 ++++ .../client/api2/MoshiChatApiTestArguments.kt | 35 +++++ ...GroupedQueryChannelsResponseAdapterTest.kt | 133 ++++++++++++++++++ .../api/stream-chat-android-core.api | 30 ++++ .../chat/android/models/GroupedChannels.kt | 43 ++++++ 13 files changed, 520 insertions(+) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/GroupedQueryChannelsRequest.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt create mode 100644 stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 4b8e6d4e130..7821eb329d0 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -107,6 +107,8 @@ public final class io/getstream/chat/android/client/ChatClient { public final fun getUnreadCounts ()Lio/getstream/result/call/Call; public static final fun getVERSION_PREFIX_HEADER ()Lio/getstream/chat/android/client/header/VersionPrefixHeader; public final fun getVideoCallToken (Ljava/lang/String;)Lio/getstream/result/call/Call; + public final fun groupedQueryChannels (Ljava/lang/Integer;ZZ)Lio/getstream/result/call/Call; + public static synthetic fun groupedQueryChannels$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/Integer;ZZILjava/lang/Object;)Lio/getstream/result/call/Call; public static final fun handlePushMessage (Lio/getstream/chat/android/models/PushMessage;)V public final fun hideChannel (Ljava/lang/String;Ljava/lang/String;Z)Lio/getstream/result/call/Call; public static synthetic fun hideChannel$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lio/getstream/result/call/Call; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index f0ae9d84b38..b14634676d3 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -184,6 +184,7 @@ import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Flag +import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Location @@ -3145,6 +3146,24 @@ internal constructor( } } + /** + * Queries channels grouped into server-defined buckets within a family. + * + * @param limit The maximum number of channels to return per bucket. `null` uses the server default. + * @param watch Whether to start watching the returned channels for real-time events. + * @param presence Whether to receive presence events for the members of the returned channels. + * + * @return A [Call] containing a [GroupedChannels] result with the family and its buckets. + */ + @CheckResult + public fun groupedQueryChannels( + limit: Int? = null, + watch: Boolean = false, + presence: Boolean = false, + ): Call { + return api.groupedQueryChannels(limit = limit, watch = watch, presence = presence) + } + /** * Deletes the channel specified by the [channelType] and [channelId]. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt index 7b6225e791f..fae2877fdbf 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt @@ -37,6 +37,7 @@ import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Flag +import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member @@ -288,6 +289,18 @@ internal interface ChatApi { @CheckResult fun queryChannels(query: QueryChannelsRequest): Call> + /** + * Queries channels grouped into server-defined buckets within a family. + * + * @param limit The maximum number of channels to return per bucket. `null` uses the server default. + * @param watch Whether to start watching the returned channels for real-time events. + * @param presence Whether to receive presence events for the members of the returned channels. + * + * @return A [Call] containing a [GroupedChannels] result with the family and its buckets. + */ + @CheckResult + fun groupedQueryChannels(limit: Int?, watch: Boolean, presence: Boolean): Call + @CheckResult fun updateUsers(users: List): Call> 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 da2a06b54c7..008bdc0aa3f 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 @@ -56,6 +56,7 @@ import io.getstream.chat.android.client.api2.model.requests.CreatePollRequest import io.getstream.chat.android.client.api2.model.requests.FlagMessageRequest import io.getstream.chat.android.client.api2.model.requests.FlagRequest import io.getstream.chat.android.client.api2.model.requests.FlagUserRequest +import io.getstream.chat.android.client.api2.model.requests.GroupedQueryChannelsRequest import io.getstream.chat.android.client.api2.model.requests.GuestUserRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.InviteMembersRequest @@ -126,6 +127,8 @@ import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Flag +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsBucket import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member @@ -1315,6 +1318,35 @@ constructor( } } + override fun groupedQueryChannels(limit: Int?, watch: Boolean, presence: Boolean): Call { + val body = GroupedQueryChannelsRequest(limit = limit, watch = watch, presence = presence) + val lazyCall = { + channelApi.groupedQueryChannels( + connectionId = connectionId, + body = body, + ).map { response -> + GroupedChannels( + family = response.family, + buckets = response.buckets.map { bucket -> + GroupedChannelsBucket( + key = bucket.key, + channels = bucket.channels.map(::flattenChannel), + unreadCount = bucket.unread_count, + unreadChannels = bucket.unread_channels, + ) + }, + ) + } + } + val isConnectionRequired = watch || presence + return if (isConnectionRequired && connectionId.isBlank()) { + logger.i { "[groupedQueryChannels] postponing because an active connection is required" } + postponeCall(lazyCall) + } else { + lazyCall() + } + } + override fun queryChannel(channelType: String, channelId: String, query: QueryChannelRequest): Call { val request = io.getstream.chat.android.client.api2.model.requests.QueryChannelRequest( state = query.state, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt index 4772aa36a4c..44e59d3eda4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt @@ -21,6 +21,7 @@ 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.AcceptInviteRequest import io.getstream.chat.android.client.api2.model.requests.AddMembersRequest +import io.getstream.chat.android.client.api2.model.requests.GroupedQueryChannelsRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.InviteMembersRequest import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest @@ -41,6 +42,7 @@ import io.getstream.chat.android.client.api2.model.requests.UpdateMemberPartialR import io.getstream.chat.android.client.api2.model.response.ChannelResponse import io.getstream.chat.android.client.api2.model.response.CompletableResponse import io.getstream.chat.android.client.api2.model.response.EventResponse +import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse import io.getstream.chat.android.client.call.RetrofitCall @@ -62,6 +64,18 @@ internal interface ChannelApi { @Body request: QueryChannelsRequest, ): RetrofitCall + /** + * Queries channels grouped into server-defined buckets within a family. + * + * @param connectionId The current connection ID. + * @param body The request body containing limit, watch, and presence parameters. + */ + @POST("/channels/grouped") + fun groupedQueryChannels( + @Query(QueryParams.CONNECTION_ID) connectionId: String, + @Body body: GroupedQueryChannelsRequest, + ): RetrofitCall + @POST("/channels/{type}/query") fun queryChannel( @Path("type") channelType: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/GroupedQueryChannelsRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/GroupedQueryChannelsRequest.kt new file mode 100644 index 00000000000..20531026662 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/GroupedQueryChannelsRequest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-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.model.requests + +import com.squareup.moshi.JsonClass + +/** + * Request body for the grouped query channels endpoint (`POST /channels/grouped`). + * + * @param limit The maximum number of channels to return per bucket. `null` uses the server default. + * @param watch Whether to start watching the returned channels for real-time events. + * @param presence Whether to receive presence events for the members of the returned channels. + */ +@JsonClass(generateAdapter = true) +internal data class GroupedQueryChannelsRequest( + val limit: Int?, + val watch: Boolean, + val presence: Boolean, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt new file mode 100644 index 00000000000..194e2d501cf --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt @@ -0,0 +1,49 @@ +/* + * 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.model.response + +import com.squareup.moshi.JsonClass + +/** + * Raw API response for the grouped query channels endpoint (`POST /channels/grouped`). + * + * @param family The family identifier that groups the buckets (e.g. "support"). + * @param buckets The list of channel buckets belonging to this family. + * @param duration The server-reported request duration (e.g. "12ms"). + */ +@JsonClass(generateAdapter = true) +internal data class GroupedQueryChannelsResponse( + val family: String, + val buckets: List, + val duration: String, +) + +/** + * A single bucket within a [GroupedQueryChannelsResponse]. + * + * @param key The backend-defined key for this bucket within the family (e.g. "all-open"). + * @param channels The channel responses that belong to this bucket. + * @param unread_count The total number of unread messages across all channels in this bucket. + * @param unread_channels The number of channels with unread messages in this bucket. + */ +@JsonClass(generateAdapter = true) +internal data class GroupedQueryChannelsBucket( + val key: String, + val channels: List, + val unread_count: Int, + val unread_channels: Int, +) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt new file mode 100644 index 00000000000..85e06b0c287 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt @@ -0,0 +1,87 @@ +/* + * 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 + +import io.getstream.chat.android.client.chatclient.BaseChatClientTest +import io.getstream.chat.android.client.utils.RetroError +import io.getstream.chat.android.client.utils.RetroSuccess +import io.getstream.chat.android.client.utils.verifyNetworkError +import io.getstream.chat.android.client.utils.verifySuccess +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsBucket +import io.getstream.chat.android.positiveRandomInt +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomInt +import io.getstream.chat.android.randomString +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.whenever + +/** + * Tests for the [ChatClient.groupedQueryChannels] endpoint. + */ +internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { + + @Test + fun groupedQueryChannelsSuccess() = runTest { + // given + val groupedChannels = GroupedChannels( + family = randomString(), + buckets = listOf( + GroupedChannelsBucket( + key = randomString(), + channels = listOf(randomChannel()), + unreadCount = randomInt(), + unreadChannels = randomInt(), + ), + ), + ) + val sut = Fixture() + .givenGroupedQueryChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall()) + .get() + // when + val result = sut.groupedQueryChannels().await() + // then + verifySuccess(result, groupedChannels) + } + + @Test + fun groupedQueryChannelsError() = runTest { + // given + val errorCode = positiveRandomInt() + val sut = Fixture() + .givenGroupedQueryChannelsResult(RetroError(errorCode).toRetrofitCall()) + .get() + // when + val result = sut.groupedQueryChannels().await() + // then + verifyNetworkError(result, errorCode) + } + + internal inner class Fixture { + + fun givenGroupedQueryChannelsResult( + result: io.getstream.result.call.Call, + ) = apply { + whenever(api.groupedQueryChannels(anyOrNull(), any(), any())).thenReturn(result) + } + + fun get(): ChatClient = chatClient + } +} 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 c228cf4520a..73b04f0e137 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 @@ -50,6 +50,7 @@ import io.getstream.chat.android.client.api2.model.requests.CreatePollRequest import io.getstream.chat.android.client.api2.model.requests.DeliveredMessageDto import io.getstream.chat.android.client.api2.model.requests.FlagMessageRequest import io.getstream.chat.android.client.api2.model.requests.FlagUserRequest +import io.getstream.chat.android.client.api2.model.requests.GroupedQueryChannelsRequest import io.getstream.chat.android.client.api2.model.requests.GuestUserRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest @@ -89,6 +90,7 @@ import io.getstream.chat.android.client.api2.model.response.DevicesResponse import io.getstream.chat.android.client.api2.model.response.DraftMessageResponse import io.getstream.chat.android.client.api2.model.response.EventResponse import io.getstream.chat.android.client.api2.model.response.FlagResponse +import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.MuteUserResponse @@ -1892,6 +1894,34 @@ internal class MoshiChatApiTest { verify(api, times(1)).queryChannels(connectionId, expectedPayload) } + @ParameterizedTest + @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#groupedQueryChannelsInput") + fun testGroupedQueryChannels( + call: RetrofitCall, + expected: KClass<*>, + ) = runTest { + // given + val api = mock() + whenever(api.groupedQueryChannels(any(), any())).doReturn(call) + val sut = Fixture() + .withChannelApi(api) + .get() + // when + val userId = randomString() + val connectionId = randomString() + val limit = randomInt() + sut.setConnection(userId = userId, connectionId = connectionId) + val result = sut.groupedQueryChannels(limit = limit, watch = false, presence = false).await() + // then + val expectedPayload = GroupedQueryChannelsRequest( + limit = limit, + watch = false, + presence = false, + ) + result `should be instance of` expected + verify(api, times(1)).groupedQueryChannels(connectionId, expectedPayload) + } + @ParameterizedTest @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#queryChannelInput") fun testQueryChannelWithoutChannelId(call: RetrofitCall, expected: KClass<*>) = runTest { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index 28bc8b72ab8..fe9b69637e1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -39,6 +39,8 @@ import io.getstream.chat.android.client.api2.model.response.CompletableResponse import io.getstream.chat.android.client.api2.model.response.DevicesResponse import io.getstream.chat.android.client.api2.model.response.EventResponse import io.getstream.chat.android.client.api2.model.response.FlagResponse +import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsBucket +import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.MuteUserResponse @@ -438,6 +440,39 @@ internal object MoshiChatApiTestArguments { Arguments.of(RetroError(statusCode = 500).toRetrofitCall(), Result.Failure::class), ) + @JvmStatic + fun groupedQueryChannelsInput() = listOf( + Arguments.of( + RetroSuccess( + GroupedQueryChannelsResponse( + family = "support", + buckets = listOf( + GroupedQueryChannelsBucket( + key = "all-open", + channels = listOf( + ChannelResponse( + channel = Mother.randomDownstreamChannelDto(), + hidden = randomBoolean(), + membership = Mother.randomDownstreamMemberDto(), + hide_messages_before = randomDateOrNull(), + draft = randomDownstreamDraftDto(), + ), + ), + unread_count = randomInt(), + unread_channels = randomInt(), + ), + ), + duration = "12ms", + ), + ).toRetrofitCall(), + Result.Success::class, + ), + Arguments.of( + RetroError(statusCode = 500).toRetrofitCall(), + Result.Failure::class, + ), + ) + @JvmStatic fun queryChannelInput() = channelResponseArguments() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt new file mode 100644 index 00000000000..ea5f80dbc33 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt @@ -0,0 +1,133 @@ +/* + * 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.parser2 + +import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Tests for JSON deserialization of [GroupedQueryChannelsResponse] using Moshi. + */ +internal class GroupedQueryChannelsResponseAdapterTest { + private val parser = ParserFactory.createMoshiChatParser() + + @Language("JSON") + private val json = """ + { + "family": "support", + "buckets": [ + { + "key": "all-open", + "channels": [ + { + "channel": { + "cid": "messaging:support-123", + "id": "support-123", + "type": "messaging", + "name": "Support", + "image": "https://getstream.imgix.net/images/random_svg/stream_logo.svg", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "frozen": false, + "disabled": false, + "config": { + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "quotes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "polls": false, + "mutes": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "commands": [], + "mark_messages_pending": false + }, + "own_capabilities": [], + "member_count": 0 + }, + "members": [], + "messages": [], + "pinned_messages": [], + "watchers": [], + "watcher_count": 0, + "read": [] + } + ], + "unread_count": 3, + "unread_channels": 1 + } + ], + "duration": "12ms" + } + """.trimIndent() + + @Test + fun `Deserialize grouped query channels response`() { + val response = parser.fromJson(json, GroupedQueryChannelsResponse::class.java) + + assertEquals("support", response.family) + assertEquals("12ms", response.duration) + assertEquals(1, response.buckets.size) + + val bucket = response.buckets[0] + assertEquals("all-open", bucket.key) + assertEquals(3, bucket.unread_count) + assertEquals(1, bucket.unread_channels) + assertEquals(1, bucket.channels.size) + + val channelResponse = bucket.channels[0] + assertEquals("messaging:support-123", channelResponse.channel.cid) + assertEquals("support-123", channelResponse.channel.id) + assertEquals("messaging", channelResponse.channel.type) + assertEquals("Support", channelResponse.channel.name) + assertEquals("https://getstream.imgix.net/images/random_svg/stream_logo.svg", channelResponse.channel.image) + assertFalse(channelResponse.channel.frozen) + assertEquals(0, channelResponse.channel.member_count) + assertTrue(channelResponse.channel.config.typing_events) + assertTrue(channelResponse.channel.config.read_events) + assertTrue(channelResponse.channel.config.connect_events) + assertTrue(channelResponse.channel.config.search) + assertTrue(channelResponse.channel.config.reactions) + assertTrue(channelResponse.channel.config.replies) + assertTrue(channelResponse.channel.config.uploads) + assertTrue(channelResponse.channel.config.url_enrichment) + assertTrue(channelResponse.channel.config.mutes) + assertEquals("infinite", channelResponse.channel.config.message_retention) + assertEquals(5000, channelResponse.channel.config.max_message_length) + assertEquals(emptyList(), channelResponse.members) + assertEquals(emptyList(), channelResponse.messages) + assertEquals(emptyList(), channelResponse.pinned_messages) + assertEquals(emptyList(), channelResponse.watchers) + assertEquals(0, channelResponse.watcher_count) + assertEquals(emptyList(), channelResponse.read) + } +} diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 528c463be21..18b8b3d86e5 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1081,6 +1081,36 @@ public final class io/getstream/chat/android/models/GreaterThanOrEqualsFilterObj public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/models/GroupedChannels { + public fun (Ljava/lang/String;Ljava/util/List;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun copy (Ljava/lang/String;Ljava/util/List;)Lio/getstream/chat/android/models/GroupedChannels; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannels;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannels; + public fun equals (Ljava/lang/Object;)Z + public final fun getBuckets ()Ljava/util/List; + public final fun getFamily ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/models/GroupedChannelsBucket { + public fun (Ljava/lang/String;Ljava/util/List;II)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()I + public final fun component4 ()I + public final fun copy (Ljava/lang/String;Ljava/util/List;II)Lio/getstream/chat/android/models/GroupedChannelsBucket; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsBucket;Ljava/lang/String;Ljava/util/List;IIILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsBucket; + public fun equals (Ljava/lang/Object;)Z + public final fun getChannels ()Ljava/util/List; + public final fun getKey ()Ljava/lang/String; + public final fun getUnreadChannels ()I + public final fun getUnreadCount ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/models/GuestUser { public fun (Lio/getstream/chat/android/models/User;Ljava/lang/String;)V public final fun component1 ()Lio/getstream/chat/android/models/User; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt new file mode 100644 index 00000000000..160de90b5d8 --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt @@ -0,0 +1,43 @@ +/* + * 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.models + +/** + * A grouped channels response returned by [ChatClient.groupedQueryChannels]. + * + * @param family The grouped channel family configured for the current app. + * @param buckets The grouped channel buckets returned by the backend in response order. + */ +public data class GroupedChannels( + public val family: String, + public val buckets: List, +) + +/** + * A grouped channels bucket returned by [io.getstream.chat.android.client.ChatClient.groupedQueryChannels]. + * + * @param key The backend-defined key for this bucket within the family. + * @param channels The channels that belong to this bucket. + * @param unreadCount The total unread message count across the bucket. + * @param unreadChannels The total unread channel count in the bucket. + */ +public data class GroupedChannelsBucket( + public val key: String, + public val channels: List, + public val unreadCount: Int, + public val unreadChannels: Int, +) From 40e1951b71234c80fe92c822d5838d18a35194d5 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 17 Apr 2026 20:13:23 +0200 Subject: [PATCH 02/21] Add GroupedQueryChannels event handling. --- .../chat/android/client/test/Mother.kt | 33 ++++++ .../api/stream-chat-android-client.api | 83 ++++++++----- .../chat/android/client/ChatClient.kt | 5 + .../chat/android/client/api2/MoshiChatApi.kt | 14 +-- .../client/api2/mapping/EventMapping.kt | 6 + .../client/api2/model/dto/EventDtos.kt | 6 + .../response/GroupedQueryChannelsResponse.kt | 24 ++-- .../chat/android/client/events/ChatEvent.kt | 35 +++++- .../chat/android/client/plugin/Plugin.kt | 12 ++ .../listeners/GroupedQueryChannelsListener.kt | 41 +++++++ .../ChatClientGroupedChannelsApiTests.kt | 8 +- .../android/client/EventChatJsonProvider.kt | 6 + .../client/api2/MoshiChatApiTestArguments.kt | 8 +- .../api2/mapping/EventMappingTestArguments.kt | 13 ++ .../android/client/parser/EventArguments.kt | 7 ++ ...GroupedQueryChannelsResponseAdapterTest.kt | 91 ++++++++++++-- .../api/stream-chat-android-core.api | 32 +++-- .../chat/android/models/GroupedChannels.kt | 24 ++-- .../api/stream-chat-android-state.api | 1 + .../internal/EventHandlerSequential.kt | 6 + .../state/plugin/internal/StatePlugin.kt | 3 + .../GroupedQueryChannelsListenerState.kt | 41 +++++++ .../state/plugin/state/global/GlobalState.kt | 8 ++ .../global/internal/MutableGlobalState.kt | 7 ++ .../internal/EventHandlerSequentialTest.kt | 112 ++++++++++++++++++ .../GroupedQueryChannelsListenerStateTest.kt | 82 +++++++++++++ 26 files changed, 594 insertions(+), 114 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener.kt create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index 2194011100d..3e93dc7d614 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.test import io.getstream.chat.android.client.events.AnswerCastedEvent import io.getstream.chat.android.client.events.ChannelDeletedEvent import io.getstream.chat.android.client.events.ChannelHiddenEvent +import io.getstream.chat.android.client.events.ChannelTruncatedEvent import io.getstream.chat.android.client.events.ChannelUpdatedByUserEvent import io.getstream.chat.android.client.events.ChannelUpdatedEvent import io.getstream.chat.android.client.events.ChannelUserBannedEvent @@ -199,6 +200,28 @@ public fun randomChannelDeletedEvent( channel = channel, ) +public fun randomChannelTruncatedEvent( + createdAt: Date = Date(), + cid: String = randomCID(), + channelType: String = randomString(), + channelId: String = randomString(), + channel: Channel = randomChannel(), + user: User? = randomUser(), + message: Message? = null, + groupedUnreadChannels: Map? = null, +): ChannelTruncatedEvent = ChannelTruncatedEvent( + type = EventType.CHANNEL_TRUNCATED, + createdAt = createdAt, + rawCreatedAt = streamFormatter.format(createdAt), + cid = cid, + channelType = channelType, + channelId = channelId, + channel = channel, + user = user, + message = message, + groupedUnreadChannels = groupedUnreadChannels, +) + public fun randomNotificationChannelDeletedEvent( createdAt: Date = Date(), cid: String = randomCID(), @@ -207,6 +230,7 @@ public fun randomNotificationChannelDeletedEvent( channel: Channel = randomChannel(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationChannelDeletedEvent { return NotificationChannelDeletedEvent( type = EventType.NOTIFICATION_CHANNEL_DELETED, @@ -218,6 +242,7 @@ public fun randomNotificationChannelDeletedEvent( channel = channel, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) } @@ -298,6 +323,7 @@ public fun randomNotificationMarkReadEvent( thread: ThreadInfo? = randomThreadInfo(), unreadThreads: Int? = randomInt(), unreadThreadMessages: Int? = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationMarkReadEvent = NotificationMarkReadEvent( type = EventType.NOTIFICATION_MARK_READ, createdAt = createdAt, @@ -313,6 +339,7 @@ public fun randomNotificationMarkReadEvent( thread = thread, unreadThreads = unreadThreads, unreadThreadMessages = unreadThreadMessages, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomNotificationMarkUnreadEvent( @@ -330,6 +357,7 @@ public fun randomNotificationMarkUnreadEvent( threadId: String? = randomString(), unreadThreads: Int = randomInt(), unreadThreadMessages: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationMarkUnreadEvent = NotificationMarkUnreadEvent( type = EventType.NOTIFICATION_MARK_UNREAD, createdAt = createdAt, @@ -347,6 +375,7 @@ public fun randomNotificationMarkUnreadEvent( unreadMessages = unreadMessages, lastReadMessageAt = lastReadMessageAt, lastReadMessageId = lastReadMessageId, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomTypingStopEvent( @@ -442,6 +471,7 @@ public fun randomNotificationMessageNewEvent( message: Message = randomMessage(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationMessageNewEvent = NotificationMessageNewEvent( type = EventType.NOTIFICATION_MESSAGE_NEW, createdAt = createdAt, @@ -453,6 +483,7 @@ public fun randomNotificationMessageNewEvent( message = message, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomMessageUpdateEvent( @@ -526,6 +557,7 @@ public fun randomNewMessageEvent( totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), channelMessageCount: Int? = positiveRandomInt(), + groupedUnreadChannels: Map? = null, ): NewMessageEvent { return NewMessageEvent( type = EventType.MESSAGE_NEW, @@ -540,6 +572,7 @@ public fun randomNewMessageEvent( totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, channelMessageCount = channelMessageCount, + groupedUnreadChannels = groupedUnreadChannels, ) } diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 7821eb329d0..2ba5dc64c08 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -1185,9 +1185,11 @@ public final class io/getstream/chat/android/client/events/ChannelHiddenEvent : public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/ChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;)V +public final class io/getstream/chat/android/client/events/ChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1196,14 +1198,15 @@ public final class io/getstream/chat/android/client/events/ChannelTruncatedEvent public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/User; public final fun component9 ()Lio/getstream/chat/android/models/Message; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;)Lio/getstream/chat/android/client/events/ChannelTruncatedEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelTruncatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelTruncatedEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)Lio/getstream/chat/android/client/events/ChannelTruncatedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelTruncatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelTruncatedEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public final fun getMessage ()Lio/getstream/chat/android/models/Message; public fun getRawCreatedAt ()Ljava/lang/String; public fun getType ()Ljava/lang/String; @@ -1512,6 +1515,10 @@ public abstract interface class io/getstream/chat/android/client/events/HasChann public abstract fun getChannel ()Lio/getstream/chat/android/models/Channel; } +public abstract interface class io/getstream/chat/android/client/events/HasGroupedUnreadChannels { + public abstract fun getGroupedUnreadChannels ()Ljava/util/Map; +} + public abstract interface class io/getstream/chat/android/client/events/HasMember { public abstract fun getMember ()Lio/getstream/chat/android/models/Member; } @@ -1778,13 +1785,14 @@ public final class io/getstream/chat/android/client/events/MessageUpdatedEvent : public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NewMessageEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasWatcherCount, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NewMessageEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasWatcherCount, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()I public final fun component12 ()Ljava/lang/Integer; + public final fun component13 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -1793,14 +1801,15 @@ public final class io/getstream/chat/android/client/events/NewMessageEvent : io/ public final fun component7 ()Ljava/lang/String; public final fun component8 ()Lio/getstream/chat/android/models/Message; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;)Lio/getstream/chat/android/client/events/NewMessageEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NewMessageEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NewMessageEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NewMessageEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NewMessageEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NewMessageEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public final fun getChannelMessageCount ()Ljava/lang/Integer; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getMessage ()Lio/getstream/chat/android/models/Message; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I @@ -1842,10 +1851,11 @@ public final class io/getstream/chat/android/client/events/NotificationAddedToCh public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationChannelDeletedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationChannelDeletedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1854,14 +1864,15 @@ public final class io/getstream/chat/android/client/events/NotificationChannelDe public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;II)Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I public fun getType ()Ljava/lang/String; @@ -1994,15 +2005,16 @@ public final class io/getstream/chat/android/client/events/NotificationInvitedEv public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationMarkReadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasUnreadThreadCounts, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationMarkReadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasUnreadThreadCounts, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Ljava/lang/String; public final fun component11 ()Ljava/lang/String; public final fun component12 ()Lio/getstream/chat/android/models/ThreadInfo; public final fun component13 ()Ljava/lang/Integer; public final fun component14 ()Ljava/lang/Integer; + public final fun component15 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -2011,13 +2023,14 @@ public final class io/getstream/chat/android/client/events/NotificationMarkReadE public final fun component7 ()Ljava/lang/String; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public final fun getLastReadMessageId ()Ljava/lang/String; public fun getRawCreatedAt ()Ljava/lang/String; public final fun getThread ()Lio/getstream/chat/android/models/ThreadInfo; @@ -2032,9 +2045,9 @@ public final class io/getstream/chat/android/client/events/NotificationMarkReadE public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationMarkUnreadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasUnreadThreadCounts, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationMarkUnreadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasUnreadThreadCounts, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()Ljava/lang/String; @@ -2043,6 +2056,7 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public final fun component14 ()Ljava/lang/String; public final fun component15 ()I public final fun component16 ()Ljava/lang/Integer; + public final fun component17 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -2051,14 +2065,15 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public final fun component7 ()Ljava/lang/String; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; public final fun getFirstUnreadMessageId ()Ljava/lang/String; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public final fun getLastReadMessageAt ()Ljava/util/Date; public final fun getLastReadMessageId ()Ljava/lang/String; public fun getRawCreatedAt ()Ljava/lang/String; @@ -2074,11 +2089,12 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationMessageNewEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationMessageNewEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I + public final fun component11 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -2087,14 +2103,15 @@ public final class io/getstream/chat/android/client/events/NotificationMessageNe public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/Message; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;II)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMessageNewEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMessageNewEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getMessage ()Lio/getstream/chat/android/models/Message; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I @@ -3122,7 +3139,7 @@ public final class io/getstream/chat/android/client/persistence/db/dao/MessageRe public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/BlockUserListener, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageForMeListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/DraftMessageListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/LiveLocationListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/PushPreferencesListener, io/getstream/chat/android/client/plugin/listeners/QueryBlockedUsersListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener, io/getstream/chat/android/client/plugin/listeners/UnblockUserListener { +public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/BlockUserListener, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageForMeListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/DraftMessageListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/LiveLocationListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/PushPreferencesListener, io/getstream/chat/android/client/plugin/listeners/QueryBlockedUsersListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener, io/getstream/chat/android/client/plugin/listeners/UnblockUserListener { public fun getErrorHandler ()Lio/getstream/chat/android/client/errorhandler/ErrorHandler; public fun onAttachmentSendRequest (Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onAttachmentSendRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3172,6 +3189,8 @@ public abstract interface class io/getstream/chat/android/client/plugin/Plugin : public fun onGetRepliesResult (Lio/getstream/result/Result;Ljava/lang/String;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onGetRepliesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/String;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onGiphySendResult (Ljava/lang/String;Lio/getstream/result/Result;)V + public fun onGroupedQueryChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onGroupedQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onHideChannelPrecondition (Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onHideChannelPrecondition$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onHideChannelRequest (Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3303,6 +3322,10 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener public abstract fun onGetMessageResult (Ljava/lang/String;Lio/getstream/result/Result;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener { + public abstract fun onGroupedQueryChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/client/plugin/listeners/HideChannelListener { public abstract fun onHideChannelPrecondition (Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onHideChannelRequest (Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index b14634676d3..39a131f8277 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -3162,6 +3162,11 @@ internal constructor( presence: Boolean = false, ): Call { return api.groupedQueryChannels(limit = limit, watch = watch, presence = presence) + .doOnResult(userScope) { result -> + plugins.forEach { plugin -> + plugin.onGroupedQueryChannelsResult(result, limit, watch, presence) + } + } } /** 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 008bdc0aa3f..ce52cef714f 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 @@ -128,7 +128,7 @@ import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Flag import io.getstream.chat.android.models.GroupedChannels -import io.getstream.chat.android.models.GroupedChannelsBucket +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member @@ -1326,13 +1326,11 @@ constructor( body = body, ).map { response -> GroupedChannels( - family = response.family, - buckets = response.buckets.map { bucket -> - GroupedChannelsBucket( - key = bucket.key, - channels = bucket.channels.map(::flattenChannel), - unreadCount = bucket.unread_count, - unreadChannels = bucket.unread_channels, + groups = response.groups.mapValues { entry -> + GroupedChannelsGroup( + channels = entry.value.channels.map(::flattenChannel), + unreadCount = entry.value.unread_count, + unreadChannels = entry.value.unread_channels, ) }, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index 95482d5c479..af4390e76df 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -285,6 +285,7 @@ internal class EventMapping( user = user?.toDomain(), message = message?.toDomain(channel.toChannelInfo()), channel = channel.toDomain(), + groupedUnreadChannels = grouped_unread_channels, ) } @@ -487,6 +488,7 @@ internal class EventMapping( totalUnreadCount = total_unread_count, unreadChannels = unread_channels, channelMessageCount = channel_message_count, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -522,6 +524,7 @@ internal class EventMapping( channel = channel.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -625,6 +628,7 @@ internal class EventMapping( unreadThreads = unread_threads, unreadThreadMessages = unread_thread_messages, lastReadMessageId = last_read_message_id, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -648,6 +652,7 @@ internal class EventMapping( unreadMessages = unread_messages, threadId = thread_id, unreadThreads = unread_threads, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -680,6 +685,7 @@ internal class EventMapping( message = message.toDomain(channel.toChannelInfo()), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index 69a14f19241..0d4f255e311 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -56,6 +56,7 @@ internal data class ChannelTruncatedEventDto( val user: DownstreamUserDto?, val message: DownstreamMessageDto?, val channel: DownstreamChannelDto, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -197,6 +198,7 @@ internal data class NewMessageEventDto( val total_unread_count: Int = 0, val unread_channels: Int = 0, val channel_message_count: Int? = null, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -236,6 +238,7 @@ internal data class NotificationChannelDeletedEventDto( val channel: DownstreamChannelDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -307,6 +310,7 @@ internal data class NotificationMarkReadEventDto( val unread_threads: Int? = null, val unread_thread_messages: Int? = null, val last_read_message_id: String?, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -325,6 +329,7 @@ internal data class NotificationMarkUnreadEventDto( val unread_channels: Int, val thread_id: String? = null, val unread_threads: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -347,6 +352,7 @@ internal data class NotificationMessageNewEventDto( val message: DownstreamMessageDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt index 194e2d501cf..b6837536726 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt @@ -21,29 +21,25 @@ import com.squareup.moshi.JsonClass /** * Raw API response for the grouped query channels endpoint (`POST /channels/grouped`). * - * @param family The family identifier that groups the buckets (e.g. "support"). - * @param buckets The list of channel buckets belonging to this family. - * @param duration The server-reported request duration (e.g. "12ms"). + * @param groups The list of channel groups. + * @param duration The server-reported request duration. */ @JsonClass(generateAdapter = true) internal data class GroupedQueryChannelsResponse( - val family: String, - val buckets: List, + val groups: Map, val duration: String, ) /** - * A single bucket within a [GroupedQueryChannelsResponse]. + * A single group within a [GroupedQueryChannelsResponse]. * - * @param key The backend-defined key for this bucket within the family (e.g. "all-open"). - * @param channels The channel responses that belong to this bucket. - * @param unread_count The total number of unread messages across all channels in this bucket. - * @param unread_channels The number of channels with unread messages in this bucket. + * @param channels The channel responses that belong to this group. + * @param unread_count The total number of unread messages across all channels in this group. + * @param unread_channels The number of channels with unread messages in this group. */ @JsonClass(generateAdapter = true) -internal data class GroupedQueryChannelsBucket( - val key: String, +internal data class GroupedQueryChannelsGroup( val channels: List, - val unread_count: Int, - val unread_channels: Int, + val unread_count: Int?, + val unread_channels: Int?, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index b534d35ac7a..83cec2f35fb 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -124,6 +124,23 @@ public sealed interface HasUnreadThreadCounts { public val unreadThreadMessages: Int? } +/** + * Interface that marks a [ChatEvent] as having grouped unread channel counts. + * The [groupedUnreadChannels] map contains per-group unread channel counts keyed by the + * backend-provided group identifier (e.g. `{"direct": 2, "support": 5}`). + * + * The list of events which contain grouped unread channels: + * - message.new + * - notification.message_new + * - notification.mark_read + * - notification.mark_unread + * - notification.channel_deleted + * - channel.truncated + */ +public sealed interface HasGroupedUnreadChannels { + public val groupedUnreadChannels: Map? +} + /** * Triggered when a channel is deleted */ @@ -166,7 +183,8 @@ public data class ChannelTruncatedEvent( override val channel: Channel, val user: User?, val message: Message?, -) : CidEvent(), HasChannel + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), HasChannel, HasGroupedUnreadChannels /** * Triggered when a channel is updated. Could contain system [message]. @@ -355,7 +373,8 @@ public data class NewMessageEvent( override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, val channelMessageCount: Int?, -) : CidEvent(), UserEvent, HasMessage, HasWatcherCount, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), UserEvent, HasMessage, HasWatcherCount, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when the user is added to the list of channel members @@ -386,7 +405,8 @@ public data class NotificationChannelDeletedEvent( override val channel: Channel, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : CidEvent(), HasChannel, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), HasChannel, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when a channel is muted @@ -475,7 +495,8 @@ public data class NotificationMarkReadEvent( val thread: ThreadInfo? = null, override val unreadThreads: Int? = null, override val unreadThreadMessages: Int? = null, -) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts, HasGroupedUnreadChannels /** * Triggered when the the user mark as unread a conversation from a particular message @@ -497,7 +518,8 @@ public data class NotificationMarkUnreadEvent( val threadId: String? = null, override val unreadThreads: Int = 0, override val unreadThreadMessages: Int? = null, -) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts, HasGroupedUnreadChannels /** * Triggered when the total count of unread messages (across all channels the user is a member) changes @@ -525,7 +547,8 @@ public data class NotificationMessageNewEvent( override val message: Message, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : CidEvent(), HasChannel, HasMessage, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), HasChannel, HasMessage, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when a message is added to a channel as a thread reply. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt index 51facf055fd..630f6cea3bc 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt @@ -32,6 +32,7 @@ import io.getstream.chat.android.client.plugin.listeners.DraftMessageListener import io.getstream.chat.android.client.plugin.listeners.EditMessageListener import io.getstream.chat.android.client.plugin.listeners.FetchCurrentUserListener import io.getstream.chat.android.client.plugin.listeners.GetMessageListener +import io.getstream.chat.android.client.plugin.listeners.GroupedQueryChannelsListener import io.getstream.chat.android.client.plugin.listeners.HideChannelListener import io.getstream.chat.android.client.plugin.listeners.LiveLocationListener import io.getstream.chat.android.client.plugin.listeners.MarkAllReadListener @@ -54,6 +55,7 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message @@ -87,6 +89,7 @@ public interface Plugin : EditMessageListener, QueryChannelListener, QueryChannelsListener, + GroupedQueryChannelsListener, TypingEventListener, HideChannelListener, MarkAllReadListener, @@ -423,6 +426,15 @@ public interface Plugin : /* No-Op */ } + override suspend fun onGroupedQueryChannelsResult( + result: Result, + limit: Int?, + watch: Boolean, + presence: Boolean, + ) { + /* No-Op */ + } + override suspend fun onQueryThreadsPrecondition(request: QueryThreadsRequest): Result = Result.Success(Unit) override suspend fun onQueryThreadsRequest(request: QueryThreadsRequest) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener.kt new file mode 100644 index 00000000000..b8db63c9d5a --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-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.plugin.listeners + +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.result.Result + +/** + * Listener used when querying grouped channels from the backend. + */ +public interface GroupedQueryChannelsListener { + + /** + * Called when the grouped query channels request completes. + * + * @param result The result of the grouped query channels request. + * @param limit The maximum number of channels per group that was requested. + * @param watch Whether watching was requested. + * @param presence Whether presence was requested. + */ + public suspend fun onGroupedQueryChannelsResult( + result: Result, + limit: Int?, + watch: Boolean, + presence: Boolean, + ) +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt index 85e06b0c287..f2411152ba2 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt @@ -22,7 +22,7 @@ import io.getstream.chat.android.client.utils.RetroSuccess import io.getstream.chat.android.client.utils.verifyNetworkError import io.getstream.chat.android.client.utils.verifySuccess import io.getstream.chat.android.models.GroupedChannels -import io.getstream.chat.android.models.GroupedChannelsBucket +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.positiveRandomInt import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomInt @@ -42,10 +42,8 @@ internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { fun groupedQueryChannelsSuccess() = runTest { // given val groupedChannels = GroupedChannels( - family = randomString(), - buckets = listOf( - GroupedChannelsBucket( - key = randomString(), + groups = mapOf( + randomString() to GroupedChannelsGroup( channels = listOf(randomChannel()), unreadCount = randomInt(), unreadChannels = randomInt(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt index d5f08e175b8..b92ef66295e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt @@ -54,6 +54,7 @@ internal fun createChannelTruncatedEventStringJson() = "cid": "channelType:channelId", "user": ${createUserJsonString()}, "channel": ${createChannelJsonString()}, + "grouped_unread_channels": {"direct": 2, "support": 5}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), ) @@ -231,6 +232,7 @@ internal fun createNotificationChannelDeletedEventStringJson() = "cid": "channelType:channelId", "user": ${createUserJsonString()}, "channel": ${createChannelJsonString()}, + "grouped_unread_channels": {"direct": 2, "support": 5}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), ) @@ -300,6 +302,7 @@ internal fun createNotificationMarkReadEventStringJson() = "watcher_count": 3, "total_unread_count": 4, "unread_channels": 5, + "grouped_unread_channels": {"direct": 2, "support": 5}, "channel_last_message_at": "2020-06-29T06:14:28.000Z", "last_read_message_id": "09afcd85-9dbb-4da8-8d85-5a6b4268d755" """.trimIndent(), @@ -316,6 +319,7 @@ internal fun createNotificationMarkUnreadEventStringJson() = "watcher_count": 3, "total_unread_count": 4, "unread_channels": 5, + "grouped_unread_channels": {"direct": 2, "support": 5}, "unread_messages": 1, "first_unread_message_id": "09afcd85-9dbb-4da8-8d85-5a6b4268d755", "last_read_at": "2020-06-29T06:14:28.000Z", @@ -336,6 +340,7 @@ internal fun createNotificationMessageNewEventStringJson() = "watcher_count": 3, "total_unread_count": 4, "unread_channels": 5, + "grouped_unread_channels": {"direct": 2, "support": 5}, "message": ${createMessageJsonString()}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), @@ -558,6 +563,7 @@ internal fun createNewMessageEventStringJson() = "watcher_count": 3, "total_unread_count": 4, "unread_channels": 5, + "grouped_unread_channels": {"direct": 2, "support": 5}, "message": ${createMessageJsonString()}, "channel_last_message_at": "2020-06-29T06:14:28.000Z", "channel_message_count": 1 diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index fe9b69637e1..9a7d7950cbc 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -39,7 +39,7 @@ import io.getstream.chat.android.client.api2.model.response.CompletableResponse import io.getstream.chat.android.client.api2.model.response.DevicesResponse import io.getstream.chat.android.client.api2.model.response.EventResponse import io.getstream.chat.android.client.api2.model.response.FlagResponse -import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsBucket +import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsGroup import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse @@ -445,10 +445,8 @@ internal object MoshiChatApiTestArguments { Arguments.of( RetroSuccess( GroupedQueryChannelsResponse( - family = "support", - buckets = listOf( - GroupedQueryChannelsBucket( - key = "all-open", + groups = mapOf( + "all-open" to GroupedQueryChannelsGroup( channels = listOf( ChannelResponse( channel = Mother.randomDownstreamChannelDto(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index b057080ff0f..440a8ab7a93 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -204,6 +204,7 @@ internal object EventMappingTestArguments { private val UNREAD_MESSAGES = positiveRandomInt() private val TOTAL_UNREAD_COUNT = positiveRandomInt() private val UNREAD_CHANNELS = positiveRandomInt() + private val GROUPED_UNREAD_CHANNELS = mapOf("direct" to positiveRandomInt(), "support" to positiveRandomInt()) private val UNREAD_THREADS = positiveRandomInt() private val UNREAD_THREAD_MESSAGES = positiveRandomInt() private val REACTION = Mother.randomDownstreamReactionDto() @@ -230,6 +231,7 @@ internal object EventMappingTestArguments { image = CHANNEL_IMAGE, ), message = MESSAGE_WITHOUT_CHANNEL_INFO, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val draftMessageUpdatedDto = DraftMessageUpdatedEventDto( @@ -274,6 +276,7 @@ internal object EventMappingTestArguments { user = USER, message = MESSAGE, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val channelUpdatedByUserDto = ChannelUpdatedByUserEventDto( @@ -471,6 +474,7 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationChannelMutesUpdatesDto = NotificationChannelMutesUpdatedEventDto( @@ -528,6 +532,7 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, last_read_message_id = LAST_READ_MESSAGE_ID, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationMarkUnreadDto = NotificationMarkUnreadEventDto( @@ -543,6 +548,7 @@ internal object EventMappingTestArguments { unread_messages = UNREAD_MESSAGES, total_unread_count = TOTAL_UNREAD_COUNT, unread_channels = UNREAD_CHANNELS, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationMessageNewDto = NotificationMessageNewEventDto( @@ -553,6 +559,7 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, message = MESSAGE, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationThreadMessageNewDto = NotificationThreadMessageNewEventDto( @@ -838,6 +845,7 @@ internal object EventMappingTestArguments { totalUnreadCount = newMessageDto.total_unread_count, unreadChannels = newMessageDto.unread_channels, channelMessageCount = newMessageDto.channel_message_count, + groupedUnreadChannels = newMessageDto.grouped_unread_channels, ) private val draftMessageUpdatedEvent = DraftMessageUpdatedEvent( @@ -889,6 +897,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { channelTruncatedDto.channel.toDomain() }, + groupedUnreadChannels = channelTruncatedDto.grouped_unread_channels, ) private val channelUpdatedByUser = ChannelUpdatedByUserEvent( @@ -1119,6 +1128,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationChannelDeletedDto.channel.toDomain() }, + groupedUnreadChannels = notificationChannelDeletedDto.grouped_unread_channels, ) private val notificationChannelMutesUpdates = NotificationChannelMutesUpdatedEvent( @@ -1188,6 +1198,7 @@ internal object EventMappingTestArguments { channelType = notificationMarkReadDto.channel_type, channelId = notificationMarkReadDto.channel_id, lastReadMessageId = notificationMarkReadDto.last_read_message_id, + groupedUnreadChannels = notificationMarkReadDto.grouped_unread_channels, ) private val notificationMarkUnread = NotificationMarkUnreadEvent( @@ -1204,6 +1215,7 @@ internal object EventMappingTestArguments { unreadMessages = notificationMarkUnreadDto.unread_messages, totalUnreadCount = notificationMarkUnreadDto.total_unread_count, unreadChannels = notificationMarkUnreadDto.unread_channels, + groupedUnreadChannels = notificationMarkUnreadDto.grouped_unread_channels, ) private val notificationMessageNew = NotificationMessageNewEvent( @@ -1217,6 +1229,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationMessageNewDto.channel.toDomain() }, + groupedUnreadChannels = notificationMessageNewDto.grouped_unread_channels, ) private val notificationThreadMessageNew = NotificationThreadMessageNewEvent( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index dac301fe722..d3845313d08 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt @@ -134,6 +134,7 @@ internal object EventArguments { private const val unreadChannels = 5 private const val unreadMessages = 1 private const val totalUnreadCount = 4 + private val groupedUnreadChannels = mapOf("direct" to 2, "support" to 5) private val user = User( id = "bender", role = "user", @@ -263,6 +264,7 @@ internal object EventArguments { channel = channel, user = user, message = null, + groupedUnreadChannels = groupedUnreadChannels, ) private val channelTruncatedServerSideEvent = ChannelTruncatedEvent( type = EventType.CHANNEL_TRUNCATED, @@ -403,6 +405,7 @@ internal object EventArguments { channelType = channelType, channelId = channelId, channel = channel, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationChannelTruncatedEvent = NotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, @@ -456,6 +459,7 @@ internal object EventArguments { totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, lastReadMessageId = message.id, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationMarkUnreadEvent = NotificationMarkUnreadEvent( type = EventType.NOTIFICATION_MARK_UNREAD, @@ -471,6 +475,7 @@ internal object EventArguments { firstUnreadMessageId = message.id, lastReadMessageAt = date, lastReadMessageId = parentMessageId, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationMessageNewEvent = NotificationMessageNewEvent( type = EventType.NOTIFICATION_MESSAGE_NEW, @@ -483,6 +488,7 @@ internal object EventArguments { message = message, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationRemovedFromChannelEvent = NotificationRemovedFromChannelEvent( type = EventType.NOTIFICATION_REMOVED_FROM_CHANNEL, @@ -656,6 +662,7 @@ internal object EventArguments { totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, channelMessageCount = 1, + groupedUnreadChannels = groupedUnreadChannels, ) private val newMessageWithoutUnreadCountsEvent = NewMessageEvent( type = EventType.MESSAGE_NEW, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt index ea5f80dbc33..de53918bcc0 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt @@ -32,10 +32,8 @@ internal class GroupedQueryChannelsResponseAdapterTest { @Language("JSON") private val json = """ { - "family": "support", - "buckets": [ - { - "key": "all-open", + "groups": { + "all-open": { "channels": [ { "channel": { @@ -85,7 +83,62 @@ internal class GroupedQueryChannelsResponseAdapterTest { "unread_count": 3, "unread_channels": 1 } - ], + }, + "duration": "12ms" + } + """.trimIndent() + + @Language("JSON") + private val jsonWithoutUnreadCounters = """ + { + "groups": { + "expired": { + "channels": [ + { + "channel": { + "cid": "messaging:support-123", + "id": "support-123", + "type": "messaging", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "frozen": false, + "disabled": false, + "config": { + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "quotes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "polls": false, + "mutes": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "commands": [], + "mark_messages_pending": false + }, + "own_capabilities": [], + "member_count": 0 + }, + "members": [], + "messages": [], + "pinned_messages": [], + "watchers": [], + "watcher_count": 0, + "read": [] + } + ] + } + }, "duration": "12ms" } """.trimIndent() @@ -94,17 +147,15 @@ internal class GroupedQueryChannelsResponseAdapterTest { fun `Deserialize grouped query channels response`() { val response = parser.fromJson(json, GroupedQueryChannelsResponse::class.java) - assertEquals("support", response.family) assertEquals("12ms", response.duration) - assertEquals(1, response.buckets.size) + assertEquals(setOf("all-open"), response.groups.keys) - val bucket = response.buckets[0] - assertEquals("all-open", bucket.key) - assertEquals(3, bucket.unread_count) - assertEquals(1, bucket.unread_channels) - assertEquals(1, bucket.channels.size) + val group = response.groups["all-open"]!! + assertEquals(3, group.unread_count) + assertEquals(1, group.unread_channels) + assertEquals(1, group.channels.size) - val channelResponse = bucket.channels[0] + val channelResponse = group.channels[0] assertEquals("messaging:support-123", channelResponse.channel.cid) assertEquals("support-123", channelResponse.channel.id) assertEquals("messaging", channelResponse.channel.type) @@ -130,4 +181,18 @@ internal class GroupedQueryChannelsResponseAdapterTest { assertEquals(0, channelResponse.watcher_count) assertEquals(emptyList(), channelResponse.read) } + + @Test + fun `Deserialize default unread counters when missing`() { + val response = parser.fromJson(jsonWithoutUnreadCounters, GroupedQueryChannelsResponse::class.java) + + assertEquals("12ms", response.duration) + assertEquals(setOf("expired"), response.groups.keys) + + val group = response.groups["expired"]!! + assertEquals(null, group.unread_count) + assertEquals(null, group.unread_channels) + assertEquals(1, group.channels.size) + assertEquals("messaging:support-123", group.channels[0].channel.cid) + } } diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 18b8b3d86e5..610c4191d25 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1082,31 +1082,27 @@ public final class io/getstream/chat/android/models/GreaterThanOrEqualsFilterObj } public final class io/getstream/chat/android/models/GroupedChannels { - public fun (Ljava/lang/String;Ljava/util/List;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/util/List; - public final fun copy (Ljava/lang/String;Ljava/util/List;)Lio/getstream/chat/android/models/GroupedChannels; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannels;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannels; + public fun (Ljava/util/Map;)V + public final fun component1 ()Ljava/util/Map; + public final fun copy (Ljava/util/Map;)Lio/getstream/chat/android/models/GroupedChannels; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannels;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannels; public fun equals (Ljava/lang/Object;)Z - public final fun getBuckets ()Ljava/util/List; - public final fun getFamily ()Ljava/lang/String; + public final fun getGroups ()Ljava/util/Map; public fun hashCode ()I public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/models/GroupedChannelsBucket { - public fun (Ljava/lang/String;Ljava/util/List;II)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/util/List; - public final fun component3 ()I - public final fun component4 ()I - public final fun copy (Ljava/lang/String;Ljava/util/List;II)Lio/getstream/chat/android/models/GroupedChannelsBucket; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsBucket;Ljava/lang/String;Ljava/util/List;IIILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsBucket; +public final class io/getstream/chat/android/models/GroupedChannelsGroup { + public fun (Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/lang/Integer; + public final fun component3 ()Ljava/lang/Integer; + public final fun copy (Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; public fun equals (Ljava/lang/Object;)Z public final fun getChannels ()Ljava/util/List; - public final fun getKey ()Ljava/lang/String; - public final fun getUnreadChannels ()I - public final fun getUnreadCount ()I + public final fun getUnreadChannels ()Ljava/lang/Integer; + public final fun getUnreadCount ()Ljava/lang/Integer; public fun hashCode ()I public fun toString ()Ljava/lang/String; } diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt index 160de90b5d8..16d69d225d1 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt @@ -19,25 +19,19 @@ package io.getstream.chat.android.models /** * A grouped channels response returned by [ChatClient.groupedQueryChannels]. * - * @param family The grouped channel family configured for the current app. - * @param buckets The grouped channel buckets returned by the backend in response order. + * @param groups The channel groups returned by the backend in response order. */ -public data class GroupedChannels( - public val family: String, - public val buckets: List, -) +public data class GroupedChannels(public val groups: Map) /** - * A grouped channels bucket returned by [io.getstream.chat.android.client.ChatClient.groupedQueryChannels]. + * A channel group returned by [ChatClient.groupedQueryChannels]. * - * @param key The backend-defined key for this bucket within the family. - * @param channels The channels that belong to this bucket. - * @param unreadCount The total unread message count across the bucket. - * @param unreadChannels The total unread channel count in the bucket. + * @param channels The channels that belong to this group. + * @param unreadCount The total unread message count across the group. + * @param unreadChannels The total unread channel count in the group. */ -public data class GroupedChannelsBucket( - public val key: String, +public data class GroupedChannelsGroup( public val channels: List, - public val unreadCount: Int, - public val unreadChannels: Int, + public val unreadCount: Int?, + public val unreadChannels: Int?, ) diff --git a/stream-chat-android-state/api/stream-chat-android-state.api b/stream-chat-android-state/api/stream-chat-android-state.api index d745c2150b5..f8beb0ff13d 100644 --- a/stream-chat-android-state/api/stream-chat-android-state.api +++ b/stream-chat-android-state/api/stream-chat-android-state.api @@ -182,6 +182,7 @@ public abstract interface class io/getstream/chat/android/state/plugin/state/glo public abstract fun getChannelMutes ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getChannelUnreadCount ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getCurrentUserActiveLiveLocations ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getGroupedUnreadChannels ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getMuted ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getThreadDraftMessages ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getTotalUnreadCount ()Lkotlinx/coroutines/flow/StateFlow; diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt index dfb15804818..4fb705c3c02 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.client.events.DraftMessageUpdatedEvent import io.getstream.chat.android.client.events.GlobalUserBannedEvent import io.getstream.chat.android.client.events.GlobalUserUnbannedEvent import io.getstream.chat.android.client.events.HasChannel +import io.getstream.chat.android.client.events.HasGroupedUnreadChannels import io.getstream.chat.android.client.events.HasMessage import io.getstream.chat.android.client.events.HasOwnUser import io.getstream.chat.android.client.events.HasPoll @@ -317,6 +318,7 @@ internal class EventHandlerSequential( var me = clientState.user.value var totalUnreadCount = mutableGlobalState.totalUnreadCount.value var channelUnreadCount = mutableGlobalState.channelUnreadCount.value + var groupedUnreadChannels = mutableGlobalState.groupedUnreadChannels.value var unreadThreadsCount = mutableGlobalState.unreadThreadsCount.value var blockedUserIds = mutableGlobalState.blockedUserIds.value @@ -371,6 +373,9 @@ internal class EventHandlerSequential( (event as? DraftMessageUpdatedEvent)?.let { mutableGlobalState.updateDraftMessage(it.draftMessage) } (event as? DraftMessageDeletedEvent)?.let { mutableGlobalState.removeDraftMessage(it.draftMessage) } (event as? HasUnreadCounts)?.let { modifyValuesFromEvent(it) } + (event as? HasGroupedUnreadChannels)?.groupedUnreadChannels?.let { + groupedUnreadChannels = it + } (event as? HasOwnUser)?.let { modifyValuesFromUser(it.me) } (event as? HasUnreadThreadCounts)?.let { modifyUnreadThreadsCount(it) } (event as? UserUpdatedEvent) @@ -388,6 +393,7 @@ internal class EventHandlerSequential( } mutableGlobalState.setTotalUnreadCount(totalUnreadCount) mutableGlobalState.setChannelUnreadCount(channelUnreadCount) + mutableGlobalState.setGroupedUnreadChannels(groupedUnreadChannels) mutableGlobalState.setUnreadThreadsCount(unreadThreadsCount) mutableGlobalState.setBlockedUserIds(blockedUserIds) logger.v { "[updateGlobalState] completed batchId: ${batchEvent.id}" } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt index 4ae3222377a..1e148eaa0dd 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt @@ -29,6 +29,7 @@ import io.getstream.chat.android.client.plugin.listeners.DeleteReactionListener import io.getstream.chat.android.client.plugin.listeners.DraftMessageListener import io.getstream.chat.android.client.plugin.listeners.EditMessageListener import io.getstream.chat.android.client.plugin.listeners.FetchCurrentUserListener +import io.getstream.chat.android.client.plugin.listeners.GroupedQueryChannelsListener import io.getstream.chat.android.client.plugin.listeners.HideChannelListener import io.getstream.chat.android.client.plugin.listeners.LiveLocationListener import io.getstream.chat.android.client.plugin.listeners.MarkAllReadListener @@ -60,6 +61,7 @@ import io.getstream.chat.android.state.plugin.listener.internal.DeleteReactionLi import io.getstream.chat.android.state.plugin.listener.internal.DraftMessageListenerState import io.getstream.chat.android.state.plugin.listener.internal.EditMessageListenerState import io.getstream.chat.android.state.plugin.listener.internal.FetchCurrentUserListenerState +import io.getstream.chat.android.state.plugin.listener.internal.GroupedQueryChannelsListenerState import io.getstream.chat.android.state.plugin.listener.internal.HideChannelListenerState import io.getstream.chat.android.state.plugin.listener.internal.LiveLocationListenerState import io.getstream.chat.android.state.plugin.listener.internal.MarkAllReadListenerState @@ -114,6 +116,7 @@ public class StatePlugin internal constructor( ) : Plugin, QueryMembersListener by QueryMembersListenerState(logic), QueryChannelsListener by QueryChannelsListenerState(logic, queryingChannelsFree), + GroupedQueryChannelsListener by GroupedQueryChannelsListenerState(mutableGlobalState), QueryChannelListener by QueryChannelListenerState(logic), ThreadQueryListener by ThreadQueryListenerState(logic, repositoryFacade), ChannelMarkReadListener by ChannelMarkReadListenerState(stateRegistry), diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt new file mode 100644 index 00000000000..2778fa7b74d --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-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.state.plugin.listener.internal + +import io.getstream.chat.android.client.plugin.listeners.GroupedQueryChannelsListener +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.result.Result + +internal class GroupedQueryChannelsListenerState( + private val globalState: MutableGlobalState, +) : GroupedQueryChannelsListener { + + override suspend fun onGroupedQueryChannelsResult( + result: Result, + limit: Int?, + watch: Boolean, + presence: Boolean, + ) { + if (result is Result.Success) { + val groupedUnreadChannels = result.value.groups.mapValues { (_, group) -> + group.unreadChannels ?: 0 + } + globalState.setGroupedUnreadChannels(groupedUnreadChannels) + } + } +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt index 02f87fa28a6..ed3c49800d1 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt @@ -41,6 +41,14 @@ public interface GlobalState { */ public val channelUnreadCount: StateFlow + /** + * Per-group unread channel counts for the current user. + * The map keys are group identifiers provided by the backend (e.g. "direct", "support") + * and values are unread channel counts. + * Empty map when no grouped counts are available. + */ + public val groupedUnreadChannels: StateFlow> + /** * The number of unread threads for the current user. */ diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt index 7bdbaae39c2..dc0a89ef192 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt @@ -40,6 +40,7 @@ internal class MutableGlobalState( private var _totalUnreadCount: MutableStateFlow? = MutableStateFlow(0) private var _channelUnreadCount: MutableStateFlow? = MutableStateFlow(0) + private var _groupedUnreadChannels: MutableStateFlow>? = MutableStateFlow(emptyMap()) private var _unreadThreadsCount: MutableStateFlow? = MutableStateFlow(0) private var _banned: MutableStateFlow? = MutableStateFlow(false) private var _mutedUsers: MutableStateFlow>? = MutableStateFlow(emptyList()) @@ -52,6 +53,7 @@ internal class MutableGlobalState( override val totalUnreadCount: StateFlow = _totalUnreadCount!! override val channelUnreadCount: StateFlow = _channelUnreadCount!! + override val groupedUnreadChannels: StateFlow> = _groupedUnreadChannels!! override val unreadThreadsCount: StateFlow = _unreadThreadsCount!! override val muted: StateFlow> = _mutedUsers!! override val channelMutes: StateFlow> = _channelMutes!! @@ -71,6 +73,7 @@ internal class MutableGlobalState( fun destroy() { _totalUnreadCount = null _channelUnreadCount = null + _groupedUnreadChannels = null _unreadThreadsCount = null _mutedUsers = null _channelMutes = null @@ -90,6 +93,10 @@ internal class MutableGlobalState( _channelUnreadCount?.value = channelUnreadCount } + fun setGroupedUnreadChannels(groupedUnreadChannels: Map) { + _groupedUnreadChannels?.value = groupedUnreadChannels + } + fun setUnreadThreadsCount(unreadThreadsCount: Int) { _unreadThreadsCount?.value = unreadThreadsCount } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt index 3d3fac45a5a..f49a0b6c7dd 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.test.randomChannelDeletedEvent +import io.getstream.chat.android.client.test.randomChannelTruncatedEvent import io.getstream.chat.android.client.test.randomChannelUpdatedEvent import io.getstream.chat.android.client.test.randomConnectedEvent import io.getstream.chat.android.client.test.randomMarkAllReadEvent @@ -151,6 +152,27 @@ internal class EventHandlerSequentialTest { verify(repos).deletePoll(event.poll.id) } + @ParameterizedTest + @MethodSource("groupedUnreadChannelsArguments") + internal fun `GlobalState should be updated with proper groupedUnreadChannels values`( + events: List, + initialGroupedUnreadChannels: Map, + prepareFixture: Fixture.() -> Unit, + expectedGroupedUnreadChannels: Map, + ) = runTest { + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(initialGroupedUnreadChannels) + } + val handler = Fixture() + .withMutableGlobalState(mutableGlobalState) + .apply(prepareFixture) + .get(this) + + handler.handleEvents(*events.toTypedArray()) + + mutableGlobalState.groupedUnreadChannels.value `should be equal to` expectedGroupedUnreadChannels + } + @ParameterizedTest @MethodSource("sharedLocationArguments") fun `GlobalState should be updated with shared locations`( @@ -234,6 +256,96 @@ internal class EventHandlerSequentialTest { withCurrentUser(currentUser) } + private val groupedUnreadChannels = mapOf("direct" to positiveRandomInt(), "support" to positiveRandomInt()) + private val initialGroupedUnreadChannels = mapOf("old" to positiveRandomInt()) + + @Suppress("LongMethod") + @JvmStatic + fun groupedUnreadChannelsArguments() = listOf( + // NewMessageEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNewMessageEvent( + cid = randomCid, + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + prepareFixtureWithReadCapability, + groupedUnreadChannels, + ), + // NotificationMarkReadEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationMarkReadEvent( + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // NotificationMarkUnreadEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationMarkUnreadEvent( + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // NotificationMessageNewEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationMessageNewEvent( + cid = randomCid, + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // NotificationChannelDeletedEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationChannelDeletedEvent( + cid = randomCid, + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // ChannelTruncatedEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomChannelTruncatedEvent( + cid = randomCid, + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // Event with null grouped unreads preserves previous value + Arguments.of( + listOf( + randomNewMessageEvent( + cid = randomCid, + groupedUnreadChannels = null, + ), + ), + initialGroupedUnreadChannels, + prepareFixtureWithReadCapability, + initialGroupedUnreadChannels, + ), + ) + @JvmStatic fun unreadCountArguments() = unreadArgumentMarkAllReadEvent() + unreadArgumentNewMessageEvent() + diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt new file mode 100644 index 00000000000..6ff2afb185e --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt @@ -0,0 +1,82 @@ +/* + * 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.state.plugin.listener.internal + +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.result.Error +import io.getstream.result.Result +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify + +internal class GroupedQueryChannelsListenerStateTest { + + private val globalState: MutableGlobalState = mock() + private val listener = GroupedQueryChannelsListenerState(globalState) + + @Test + fun `when result is successful, grouped unread channels should be set on global state`() = runTest { + // given + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf( + "direct" to GroupedChannelsGroup(channels = emptyList(), unreadCount = 10, unreadChannels = 3), + "support" to GroupedChannelsGroup(channels = emptyList(), unreadCount = 5, unreadChannels = 1), + ), + ), + ) + // when + listener.onGroupedQueryChannelsResult(result, limit = null, watch = false, presence = false) + // then + verify(globalState, times(1)).setGroupedUnreadChannels(mapOf("direct" to 3, "support" to 1)) + } + + @Test + fun `when result is successful with null unread channels, defaults to zero`() = runTest { + // given + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf( + "expired" to GroupedChannelsGroup(channels = emptyList(), unreadCount = null, unreadChannels = null), + ), + ), + ) + // when + listener.onGroupedQueryChannelsResult(result, limit = 10, watch = true, presence = false) + // then + verify(globalState, times(1)).setGroupedUnreadChannels(mapOf("expired" to 0)) + } + + @Test + fun `when result is failure, global state should not be updated`() = runTest { + // given + val result = Result.Failure(Error.GenericError("Network error")) + // when + listener.onGroupedQueryChannelsResult(result, limit = null, watch = false, presence = false) + // then + verify(globalState, never()).setGroupedUnreadChannels(any()) + } +} From 51a256b507f09c478bb88dc3b882bf9a34375960 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 20 Apr 2026 13:43:55 +0200 Subject: [PATCH 03/21] Add GroupedQueryChannels event handling. --- .../chat/android/client/test/Mother.kt | 4 ++-- .../api/stream-chat-android-client.api | 23 +++++++++---------- .../chat/android/client/api2/MoshiChatApi.kt | 1 - .../client/api2/mapping/EventMapping.kt | 6 ++--- .../client/api2/model/dto/EventDtos.kt | 6 ++--- .../response/GroupedQueryChannelsResponse.kt | 2 -- .../chat/android/client/events/ChatEvent.kt | 6 ++--- .../ChatClientGroupedChannelsApiTests.kt | 1 - .../client/api2/MoshiChatApiTestArguments.kt | 1 - .../api2/mapping/EventMappingTestArguments.kt | 8 +++---- .../android/client/parser/EventArguments.kt | 1 - ...GroupedQueryChannelsResponseAdapterTest.kt | 3 --- .../api/stream-chat-android-core.api | 8 +++---- .../chat/android/models/GroupedChannels.kt | 2 -- .../internal/EventHandlerSequentialTest.kt | 7 +++--- .../GroupedQueryChannelsListenerStateTest.kt | 6 ++--- 16 files changed, 35 insertions(+), 50 deletions(-) diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index 3e93dc7d614..4a073cf6a26 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -208,7 +208,6 @@ public fun randomChannelTruncatedEvent( channel: Channel = randomChannel(), user: User? = randomUser(), message: Message? = null, - groupedUnreadChannels: Map? = null, ): ChannelTruncatedEvent = ChannelTruncatedEvent( type = EventType.CHANNEL_TRUNCATED, createdAt = createdAt, @@ -219,7 +218,6 @@ public fun randomChannelTruncatedEvent( channel = channel, user = user, message = message, - groupedUnreadChannels = groupedUnreadChannels, ) public fun randomNotificationChannelDeletedEvent( @@ -584,6 +582,7 @@ public fun randomNotificationChannelTruncatedEvent( channel: Channel = randomChannel(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationChannelTruncatedEvent = NotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, createdAt = createdAt, @@ -594,6 +593,7 @@ public fun randomNotificationChannelTruncatedEvent( channel = channel, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomMarkAllReadEvent( diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 2ba5dc64c08..ec0e13913d9 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -1185,11 +1185,9 @@ public final class io/getstream/chat/android/client/events/ChannelHiddenEvent : public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/ChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/ChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;)V public final fun component1 ()Ljava/lang/String; - public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1198,15 +1196,14 @@ public final class io/getstream/chat/android/client/events/ChannelTruncatedEvent public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/User; public final fun component9 ()Lio/getstream/chat/android/models/Message; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)Lio/getstream/chat/android/client/events/ChannelTruncatedEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelTruncatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelTruncatedEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;)Lio/getstream/chat/android/client/events/ChannelTruncatedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelTruncatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Message;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelTruncatedEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; - public fun getGroupedUnreadChannels ()Ljava/util/Map; public final fun getMessage ()Lio/getstream/chat/android/models/Message; public fun getRawCreatedAt ()Ljava/lang/String; public fun getType ()Ljava/lang/String; @@ -1898,10 +1895,11 @@ public final class io/getstream/chat/android/client/events/NotificationChannelMu public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1910,14 +1908,15 @@ public final class io/getstream/chat/android/client/events/NotificationChannelTr public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;II)Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I public fun getType ()Ljava/lang/String; 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 ce52cef714f..acaddd5d5dc 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 @@ -1329,7 +1329,6 @@ constructor( groups = response.groups.mapValues { entry -> GroupedChannelsGroup( channels = entry.value.channels.map(::flattenChannel), - unreadCount = entry.value.unread_count, unreadChannels = entry.value.unread_channels, ) }, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index af4390e76df..3b7e32593d5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -285,7 +285,6 @@ internal class EventMapping( user = user?.toDomain(), message = message?.toDomain(channel.toChannelInfo()), channel = channel.toDomain(), - groupedUnreadChannels = grouped_unread_channels, ) } @@ -556,6 +555,7 @@ internal class EventMapping( channel = channel.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -644,8 +644,8 @@ internal class EventMapping( cid = cid, channelType = channel_type, channelId = channel_id, - totalUnreadCount = total_unread_count, - unreadChannels = unread_channels, + totalUnreadCount = total_unread_count ?: 0, + unreadChannels = unread_channels ?: 0, firstUnreadMessageId = first_unread_message_id, lastReadMessageId = last_read_message_id, lastReadMessageAt = last_read_at.date, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index 0d4f255e311..fef93966a69 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -56,7 +56,6 @@ internal data class ChannelTruncatedEventDto( val user: DownstreamUserDto?, val message: DownstreamMessageDto?, val channel: DownstreamChannelDto, - val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -258,6 +257,7 @@ internal data class NotificationChannelTruncatedEventDto( val channel: DownstreamChannelDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -325,8 +325,8 @@ internal data class NotificationMarkUnreadEventDto( val last_read_message_id: String?, val last_read_at: ExactDate, val unread_messages: Int, - val total_unread_count: Int, - val unread_channels: Int, + val total_unread_count: Int? = null, + val unread_channels: Int? = null, val thread_id: String? = null, val unread_threads: Int = 0, val grouped_unread_channels: Map? = null, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt index b6837536726..e75d815f161 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt @@ -34,12 +34,10 @@ internal data class GroupedQueryChannelsResponse( * A single group within a [GroupedQueryChannelsResponse]. * * @param channels The channel responses that belong to this group. - * @param unread_count The total number of unread messages across all channels in this group. * @param unread_channels The number of channels with unread messages in this group. */ @JsonClass(generateAdapter = true) internal data class GroupedQueryChannelsGroup( val channels: List, - val unread_count: Int?, val unread_channels: Int?, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index 83cec2f35fb..8529c8c7599 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -183,8 +183,7 @@ public data class ChannelTruncatedEvent( override val channel: Channel, val user: User?, val message: Message?, - override val groupedUnreadChannels: Map? = null, -) : CidEvent(), HasChannel, HasGroupedUnreadChannels +) : CidEvent(), HasChannel /** * Triggered when a channel is updated. Could contain system [message]. @@ -431,7 +430,8 @@ public data class NotificationChannelTruncatedEvent( override val channel: Channel, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : CidEvent(), HasChannel, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), HasChannel, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when the user accepts an invite diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt index f2411152ba2..028c9390cdb 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt @@ -45,7 +45,6 @@ internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { groups = mapOf( randomString() to GroupedChannelsGroup( channels = listOf(randomChannel()), - unreadCount = randomInt(), unreadChannels = randomInt(), ), ), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index 9a7d7950cbc..23e8d364a9a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -456,7 +456,6 @@ internal object MoshiChatApiTestArguments { draft = randomDownstreamDraftDto(), ), ), - unread_count = randomInt(), unread_channels = randomInt(), ), ), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index 440a8ab7a93..88738e86331 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -276,7 +276,6 @@ internal object EventMappingTestArguments { user = USER, message = MESSAGE, channel = CHANNEL, - grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val channelUpdatedByUserDto = ChannelUpdatedByUserEventDto( @@ -490,6 +489,7 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationInviteAcceptedDto = NotificationInviteAcceptedEventDto( @@ -897,7 +897,6 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { channelTruncatedDto.channel.toDomain() }, - groupedUnreadChannels = channelTruncatedDto.grouped_unread_channels, ) private val channelUpdatedByUser = ChannelUpdatedByUserEvent( @@ -1148,6 +1147,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationChannelTruncatedDto.channel.toDomain() }, + groupedUnreadChannels = notificationChannelTruncatedDto.grouped_unread_channels, ) private val notificationInviteAccepted = NotificationInviteAcceptedEvent( @@ -1213,8 +1213,8 @@ internal object EventMappingTestArguments { lastReadMessageId = notificationMarkUnreadDto.last_read_message_id, lastReadMessageAt = notificationMarkUnreadDto.last_read_at.date, unreadMessages = notificationMarkUnreadDto.unread_messages, - totalUnreadCount = notificationMarkUnreadDto.total_unread_count, - unreadChannels = notificationMarkUnreadDto.unread_channels, + totalUnreadCount = notificationMarkUnreadDto.total_unread_count ?: 0, + unreadChannels = notificationMarkUnreadDto.unread_channels ?: 0, groupedUnreadChannels = notificationMarkUnreadDto.grouped_unread_channels, ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index d3845313d08..e20926db16f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt @@ -264,7 +264,6 @@ internal object EventArguments { channel = channel, user = user, message = null, - groupedUnreadChannels = groupedUnreadChannels, ) private val channelTruncatedServerSideEvent = ChannelTruncatedEvent( type = EventType.CHANNEL_TRUNCATED, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt index de53918bcc0..f9a9f312972 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt @@ -80,7 +80,6 @@ internal class GroupedQueryChannelsResponseAdapterTest { "read": [] } ], - "unread_count": 3, "unread_channels": 1 } }, @@ -151,7 +150,6 @@ internal class GroupedQueryChannelsResponseAdapterTest { assertEquals(setOf("all-open"), response.groups.keys) val group = response.groups["all-open"]!! - assertEquals(3, group.unread_count) assertEquals(1, group.unread_channels) assertEquals(1, group.channels.size) @@ -190,7 +188,6 @@ internal class GroupedQueryChannelsResponseAdapterTest { assertEquals(setOf("expired"), response.groups.keys) val group = response.groups["expired"]!! - assertEquals(null, group.unread_count) assertEquals(null, group.unread_channels) assertEquals(1, group.channels.size) assertEquals("messaging:support-123", group.channels[0].channel.cid) diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 610c4191d25..9377017dc4c 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1093,16 +1093,14 @@ public final class io/getstream/chat/android/models/GroupedChannels { } public final class io/getstream/chat/android/models/GroupedChannelsGroup { - public fun (Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;)V + public fun (Ljava/util/List;Ljava/lang/Integer;)V public final fun component1 ()Ljava/util/List; public final fun component2 ()Ljava/lang/Integer; - public final fun component3 ()Ljava/lang/Integer; - public final fun copy (Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/chat/android/models/GroupedChannelsGroup; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/util/List;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public final fun copy (Ljava/util/List;Ljava/lang/Integer;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/util/List;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; public fun equals (Ljava/lang/Object;)Z public final fun getChannels ()Ljava/util/List; public final fun getUnreadChannels ()Ljava/lang/Integer; - public final fun getUnreadCount ()Ljava/lang/Integer; public fun hashCode ()I public fun toString ()Ljava/lang/String; } diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt index 16d69d225d1..cf803e9da45 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt @@ -27,11 +27,9 @@ public data class GroupedChannels(public val groups: Map, - public val unreadCount: Int?, public val unreadChannels: Int?, ) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt index f49a0b6c7dd..d280277fd0c 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -22,7 +22,6 @@ import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.test.randomChannelDeletedEvent -import io.getstream.chat.android.client.test.randomChannelTruncatedEvent import io.getstream.chat.android.client.test.randomChannelUpdatedEvent import io.getstream.chat.android.client.test.randomConnectedEvent import io.getstream.chat.android.client.test.randomMarkAllReadEvent @@ -320,16 +319,16 @@ internal class EventHandlerSequentialTest { neutralPrepareFixture, groupedUnreadChannels, ), - // ChannelTruncatedEvent with grouped unreads updates GlobalState + // NotificationChannelTruncatedEvent with grouped unreads updates GlobalState Arguments.of( listOf( - randomChannelTruncatedEvent( + randomNotificationChannelTruncatedEvent( cid = randomCid, groupedUnreadChannels = groupedUnreadChannels, ), ), initialGroupedUnreadChannels, - neutralPrepareFixture, + prepareFixtureWithReadCapability, groupedUnreadChannels, ), // Event with null grouped unreads preserves previous value diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt index 6ff2afb185e..027ca6e5b30 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt @@ -42,8 +42,8 @@ internal class GroupedQueryChannelsListenerStateTest { val result = Result.Success( value = GroupedChannels( groups = mapOf( - "direct" to GroupedChannelsGroup(channels = emptyList(), unreadCount = 10, unreadChannels = 3), - "support" to GroupedChannelsGroup(channels = emptyList(), unreadCount = 5, unreadChannels = 1), + "direct" to GroupedChannelsGroup(channels = emptyList(), unreadChannels = 3), + "support" to GroupedChannelsGroup(channels = emptyList(), unreadChannels = 1), ), ), ) @@ -60,7 +60,7 @@ internal class GroupedQueryChannelsListenerStateTest { val result = Result.Success( value = GroupedChannels( groups = mapOf( - "expired" to GroupedChannelsGroup(channels = emptyList(), unreadCount = null, unreadChannels = null), + "expired" to GroupedChannelsGroup(channels = emptyList(), unreadChannels = null), ), ), ) From 80e23605212f8987f4ab7fd36086e36dc328df44 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 20 Apr 2026 14:09:35 +0200 Subject: [PATCH 04/21] Fix KDocs. --- .../java/io/getstream/chat/android/client/events/ChatEvent.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index 8529c8c7599..6e0adef0732 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -135,7 +135,7 @@ public sealed interface HasUnreadThreadCounts { * - notification.mark_read * - notification.mark_unread * - notification.channel_deleted - * - channel.truncated + * - notification.channel_truncated */ public sealed interface HasGroupedUnreadChannels { public val groupedUnreadChannels: Map? From 30d757f881d1de9d95e6c94f0462a53206006135 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 21 Apr 2026 10:14:27 +0200 Subject: [PATCH 05/21] Make GroupedChannelsGroup.unreadCount non-nullable. --- .../java/io/getstream/chat/android/client/api2/MoshiChatApi.kt | 2 +- .../java/io/getstream/chat/android/models/GroupedChannels.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 acaddd5d5dc..36f617238bd 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 @@ -1329,7 +1329,7 @@ constructor( groups = response.groups.mapValues { entry -> GroupedChannelsGroup( channels = entry.value.channels.map(::flattenChannel), - unreadChannels = entry.value.unread_channels, + unreadChannels = entry.value.unread_channels ?: 0, ) }, ) diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt index cf803e9da45..aa7b1fb1b67 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt @@ -31,5 +31,5 @@ public data class GroupedChannels(public val groups: Map, - public val unreadChannels: Int?, + public val unreadChannels: Int = 0, ) From 65e278c13f61a4c634e0c3cd9fe5d5a639ec30d5 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 21 Apr 2026 10:15:32 +0200 Subject: [PATCH 06/21] Make GroupedChannelsGroup.unreadCount non-nullable. --- .../listener/internal/GroupedQueryChannelsListenerState.kt | 2 +- .../listener/internal/GroupedQueryChannelsListenerStateTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt index 2778fa7b74d..60ca6f23dcf 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt @@ -33,7 +33,7 @@ internal class GroupedQueryChannelsListenerState( ) { if (result is Result.Success) { val groupedUnreadChannels = result.value.groups.mapValues { (_, group) -> - group.unreadChannels ?: 0 + group.unreadChannels } globalState.setGroupedUnreadChannels(groupedUnreadChannels) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt index 027ca6e5b30..611e5c494f3 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt @@ -60,7 +60,7 @@ internal class GroupedQueryChannelsListenerStateTest { val result = Result.Success( value = GroupedChannels( groups = mapOf( - "expired" to GroupedChannelsGroup(channels = emptyList(), unreadChannels = null), + "expired" to GroupedChannelsGroup(channels = emptyList(), unreadChannels = 0), ), ), ) From ab1efa346b7a7faea7a83c82d632b5f2592ec63b Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 22 Apr 2026 11:41:47 +0200 Subject: [PATCH 07/21] Fix passing outdated `Channel` data for `CidEvent`s to the `ChatEventHandler.handleChatEvent`. --- .../internal/QueryChannelsLogic.kt | 16 +++- .../internal/QueryChannelsStateLogic.kt | 10 +++ .../internal/QueryChannelsLogicTest.kt | 74 +++++++++++++++++++ .../internal/QueryChannelsStateLogicTest.kt | 26 +++++++ 4 files changed, 123 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index e4923cea0e7..2b234a19410 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -306,11 +306,21 @@ internal class QueryChannelsLogic( internal suspend fun parseChatEventResults(chatEvents: List): List { val cids = chatEvents.filterIsInstance().map { it.cid }.distinct() - val cachedChannels = queryChannelsDatabaseLogic - .selectChannels(cids).associateBy { it.cid } + // Prefer in-memory per-channel state which has already been updated by the channel + // event handlers. Fall back to DB for channels that are not currently active in memory. + val inMemoryChannels = cids.mapNotNull { cid -> + queryChannelsStateLogic.getActiveChannelState(cid)?.let { cid to it } + }.toMap() + val remainingCids = cids - inMemoryChannels.keys + val dbChannels = if (remainingCids.isEmpty()) { + emptyMap() + } else { + queryChannelsDatabaseLogic.selectChannels(remainingCids).associateBy { it.cid } + } + val resolvedChannels = inMemoryChannels + dbChannels return chatEvents.map { event -> - val channel = (event as? CidEvent)?.let { cachedChannels[it.cid] } + val channel = (event as? CidEvent)?.let { resolvedChannels[it.cid] } queryChannelsStateLogic.handleChatEvent(event, channel) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index f2037101838..f1d7b9a1812 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -244,6 +244,16 @@ internal class QueryChannelsStateLogic( mutableState.setChannels(newChannels) } + /** + * Returns the current [Channel] snapshot from the in-memory per-channel state if the + * channel is active, or `null` otherwise. + */ + internal fun getActiveChannelState(cid: String): Channel? { + val (type, id) = cid.cidToTypeAndId() + if (!stateRegistry.isActiveChannel(type, id)) return null + return stateRegistry.channel(type, id).toChannel() + } + /** * Refreshes member state in all channels from this query. * diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 7e2f38c0f1f..089523a6e7a 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -20,17 +20,20 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest +import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.test.TestCoroutineRule import io.getstream.chat.android.test.asCall import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any @@ -283,4 +286,75 @@ internal class QueryChannelsLogicTest { } // endregion + + // region parseChatEventResults + + @Test + fun `parseChatEventResults should resolve channels from in-memory state and skip DB`() = runTest { + // Given + val channel = randomChannel(type = "messaging", id = "ch1") + val event = randomNewMessageEvent(cid = channel.cid, channelType = "messaging", channelId = "ch1") + val expectedResult = EventHandlingResult.Skip + + whenever(queryChannelsStateLogic.getActiveChannelState(channel.cid)) doReturn channel + whenever(queryChannelsStateLogic.handleChatEvent(eq(event), eq(channel))) doReturn expectedResult + + // When + val results = logic.parseChatEventResults(listOf(event)) + + // Then + verify(queryChannelsDatabaseLogic, never()).selectChannels(any()) + assertEquals(listOf(expectedResult), results) + } + + @Test + fun `parseChatEventResults should fall back to DB when channel is not active in memory`() = runTest { + // Given + val channel = randomChannel(type = "messaging", id = "ch1") + val event = randomNewMessageEvent(cid = channel.cid, channelType = "messaging", channelId = "ch1") + val expectedResult = EventHandlingResult.Skip + + whenever(queryChannelsStateLogic.getActiveChannelState(channel.cid)) doReturn null + whenever(queryChannelsDatabaseLogic.selectChannels(listOf(channel.cid))) doReturn listOf(channel) + whenever(queryChannelsStateLogic.handleChatEvent(eq(event), eq(channel))) doReturn expectedResult + + // When + val results = logic.parseChatEventResults(listOf(event)) + + // Then + verify(queryChannelsDatabaseLogic).selectChannels(listOf(channel.cid)) + assertEquals(listOf(expectedResult), results) + } + + @Test + fun `parseChatEventResults should use mixed resolution - memory for active, DB for inactive`() = runTest { + // Given + val inMemoryChannel = randomChannel(type = "messaging", id = "active") + val dbChannel = randomChannel(type = "messaging", id = "inactive") + val event1 = randomNewMessageEvent( + cid = inMemoryChannel.cid, + channelType = "messaging", + channelId = "active", + ) + val event2 = randomNewMessageEvent( + cid = dbChannel.cid, + channelType = "messaging", + channelId = "inactive", + ) + + whenever(queryChannelsStateLogic.getActiveChannelState(inMemoryChannel.cid)) doReturn inMemoryChannel + whenever(queryChannelsStateLogic.getActiveChannelState(dbChannel.cid)) doReturn null + whenever(queryChannelsDatabaseLogic.selectChannels(listOf(dbChannel.cid))) doReturn listOf(dbChannel) + whenever(queryChannelsStateLogic.handleChatEvent(any(), any())) doReturn EventHandlingResult.Skip + + // When + logic.parseChatEventResults(listOf(event1, event2)) + + // Then – only the inactive channel should be fetched from DB + verify(queryChannelsDatabaseLogic).selectChannels(listOf(dbChannel.cid)) + verify(queryChannelsStateLogic).handleChatEvent(event1, inMemoryChannel) + verify(queryChannelsStateLogic).handleChatEvent(event2, dbChannel) + } + + // endregion } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt index 60244c79aca..4845c0ec1d7 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt @@ -32,6 +32,8 @@ import io.getstream.chat.android.test.TestCoroutineRule import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should contain same` import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.doReturn @@ -133,4 +135,28 @@ internal class QueryChannelsStateLogicTest { queryChannelsSpec.cids `should contain same` setOf(testCid, channel1.cid, channel2.cid) verify(mutableState).setChannels(channels.associateBy { it.cid }) } + + @Test + fun `getActiveChannelState should return channel when it is active in state registry`() { + val channel = randomChannel(type = type, id = id) + val channelState: ChannelState = mock { + on(it.toChannel()) doReturn channel + } + + whenever(stateRegistry.isActiveChannel(type, id)) doReturn true + whenever(stateRegistry.channel(type, id)) doReturn channelState + + val result = queryChannelsStateLogic.getActiveChannelState(testCid) + + assertEquals(channel, result) + } + + @Test + fun `getActiveChannelState should return null when channel is not active in state registry`() { + whenever(stateRegistry.isActiveChannel(type, id)) doReturn false + + val result = queryChannelsStateLogic.getActiveChannelState(testCid) + + assertNull(result) + } } From 18aefe3aeff5098cf807358e7a4acf3df9c74001 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 23 Apr 2026 12:37:23 +0200 Subject: [PATCH 08/21] Add `ChannelListViewModel.prefill(channels: List)`. --- .../api/stream-chat-android-client.api | 18 +- .../chat/android/client/ChatClient.kt | 6 +- .../chat/android/client/api/ChatApi.kt | 2 +- .../chat/android/client/api2/MoshiChatApi.kt | 10 +- .../client/api2/endpoint/ChannelApi.kt | 10 +- ...uest.kt => QueryGroupedChannelsRequest.kt} | 2 +- ...nse.kt => QueryGroupedChannelsResponse.kt} | 8 +- .../chat/android/client/plugin/Plugin.kt | 6 +- ...ner.kt => QueryGroupedChannelsListener.kt} | 8 +- .../ChatClientGroupedChannelsApiTests.kt | 18 +- .../android/client/api2/MoshiChatApiTest.kt | 18 +- .../client/api2/MoshiChatApiTestArguments.kt | 12 +- ...ueryGroupedChannelsResponseAdapterTest.kt} | 10 +- .../api/stream-chat-android-compose.api | 9 +- .../channels/ChannelListViewModel.kt | 100 ++++++- .../channels/ChannelViewModelFactory.kt | 4 + .../channels/ChannelListViewModelTest.kt | 121 ++++++++- .../api/stream-chat-android-core.api | 11 +- .../chat/android/models/GroupedChannels.kt | 4 +- .../internal/EventHandlerSequential.kt | 9 +- .../android/state/extensions/ChatClient.kt | 48 ++++ .../pagination/internal/Mapper.kt | 13 + .../state/plugin/internal/StatePlugin.kt | 6 +- .../internal/QueryChannelsListenerState.kt | 18 +- ...t => QueryGroupedChannelsListenerState.kt} | 8 +- .../internal/QueryChannelsLogic.kt | 81 ++++++ .../internal/QueryChannelsStateLogic.kt | 18 ++ .../state/internal/ChatClientStateCalls.kt | 22 ++ .../internal/QueryChannelsMutableState.kt | 2 +- .../state/sync/internal/SyncManager.kt | 1 + ... QueryGroupedChannelsListenerStateTest.kt} | 10 +- .../internal/QueryChannelsLogicTest.kt | 130 +++++++++ .../internal/QueryChannelsStateLogicTest.kt | 183 +++++++++++++ .../internal/ChatClientStateCallsTest.kt | 152 +++++++++++ .../internal/QueryChannelsMutableStateTest.kt | 256 ++++++++++++++++++ 35 files changed, 1211 insertions(+), 123 deletions(-) rename stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/{GroupedQueryChannelsRequest.kt => QueryGroupedChannelsRequest.kt} (96%) rename stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/{GroupedQueryChannelsResponse.kt => QueryGroupedChannelsResponse.kt} (85%) rename stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/{GroupedQueryChannelsListener.kt => QueryGroupedChannelsListener.kt} (83%) rename stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/{GroupedQueryChannelsResponseAdapterTest.kt => QueryGroupedChannelsResponseAdapterTest.kt} (95%) rename stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/{GroupedQueryChannelsListenerState.kt => QueryGroupedChannelsListenerState.kt} (85%) rename stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/{GroupedQueryChannelsListenerStateTest.kt => QueryGroupedChannelsListenerStateTest.kt} (90%) create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index ec0e13913d9..e5b26ea4104 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -107,8 +107,6 @@ public final class io/getstream/chat/android/client/ChatClient { public final fun getUnreadCounts ()Lio/getstream/result/call/Call; public static final fun getVERSION_PREFIX_HEADER ()Lio/getstream/chat/android/client/header/VersionPrefixHeader; public final fun getVideoCallToken (Ljava/lang/String;)Lio/getstream/result/call/Call; - public final fun groupedQueryChannels (Ljava/lang/Integer;ZZ)Lio/getstream/result/call/Call; - public static synthetic fun groupedQueryChannels$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/Integer;ZZILjava/lang/Object;)Lio/getstream/result/call/Call; public static final fun handlePushMessage (Lio/getstream/chat/android/models/PushMessage;)V public final fun hideChannel (Ljava/lang/String;Ljava/lang/String;Z)Lio/getstream/result/call/Call; public static synthetic fun hideChannel$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lio/getstream/result/call/Call; @@ -166,6 +164,8 @@ public final class io/getstream/chat/android/client/ChatClient { public final fun queryDraftMessages (Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/result/call/Call; public final fun queryDrafts (Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/result/call/Call; public static synthetic fun queryDrafts$default (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/result/call/Call; + public final fun queryGroupedChannels (Ljava/lang/Integer;ZZ)Lio/getstream/result/call/Call; + public static synthetic fun queryGroupedChannels$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/Integer;ZZILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryMembers (Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;)Lio/getstream/result/call/Call; public static synthetic fun queryMembers$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryPollVotes (Ljava/lang/String;Lio/getstream/chat/android/models/FilterObject;Ljava/lang/Integer;Ljava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/result/call/Call; @@ -3138,7 +3138,7 @@ public final class io/getstream/chat/android/client/persistence/db/dao/MessageRe public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/BlockUserListener, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageForMeListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/DraftMessageListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/LiveLocationListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/PushPreferencesListener, io/getstream/chat/android/client/plugin/listeners/QueryBlockedUsersListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener, io/getstream/chat/android/client/plugin/listeners/UnblockUserListener { +public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/BlockUserListener, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageForMeListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/DraftMessageListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/LiveLocationListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/PushPreferencesListener, io/getstream/chat/android/client/plugin/listeners/QueryBlockedUsersListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener, io/getstream/chat/android/client/plugin/listeners/UnblockUserListener { public fun getErrorHandler ()Lio/getstream/chat/android/client/errorhandler/ErrorHandler; public fun onAttachmentSendRequest (Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onAttachmentSendRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3188,8 +3188,6 @@ public abstract interface class io/getstream/chat/android/client/plugin/Plugin : public fun onGetRepliesResult (Lio/getstream/result/Result;Ljava/lang/String;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onGetRepliesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/String;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onGiphySendResult (Ljava/lang/String;Lio/getstream/result/Result;)V - public fun onGroupedQueryChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun onGroupedQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onHideChannelPrecondition (Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onHideChannelPrecondition$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onHideChannelRequest (Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3223,6 +3221,8 @@ public abstract interface class io/getstream/chat/android/client/plugin/Plugin : public fun onQueryDraftMessagesResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryDraftMessagesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryDraftMessagesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryGroupedChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryMembersResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryMembersResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryThreadsPrecondition (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3321,10 +3321,6 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener public abstract fun onGetMessageResult (Ljava/lang/String;Lio/getstream/result/Result;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public abstract interface class io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener { - public abstract fun onGroupedQueryChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - public abstract interface class io/getstream/chat/android/client/plugin/listeners/HideChannelListener { public abstract fun onHideChannelPrecondition (Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun onHideChannelRequest (Ljava/lang/String;Ljava/lang/String;ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3367,6 +3363,10 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener public static synthetic fun onQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener { + public abstract fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryMembersListener { public abstract fun onQueryMembersResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index 39a131f8277..1656a7bd9ad 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -3156,15 +3156,15 @@ internal constructor( * @return A [Call] containing a [GroupedChannels] result with the family and its buckets. */ @CheckResult - public fun groupedQueryChannels( + public fun queryGroupedChannels( limit: Int? = null, watch: Boolean = false, presence: Boolean = false, ): Call { - return api.groupedQueryChannels(limit = limit, watch = watch, presence = presence) + return api.queryGroupedChannels(limit = limit, watch = watch, presence = presence) .doOnResult(userScope) { result -> plugins.forEach { plugin -> - plugin.onGroupedQueryChannelsResult(result, limit, watch, presence) + plugin.onQueryGroupedChannelsResult(result, limit, watch, presence) } } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt index fae2877fdbf..5cd5eee40b9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt @@ -299,7 +299,7 @@ internal interface ChatApi { * @return A [Call] containing a [GroupedChannels] result with the family and its buckets. */ @CheckResult - fun groupedQueryChannels(limit: Int?, watch: Boolean, presence: Boolean): Call + fun queryGroupedChannels(limit: Int?, watch: Boolean, presence: Boolean): Call @CheckResult fun updateUsers(users: List): Call> 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 36f617238bd..baa5a79b3ef 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 @@ -56,7 +56,6 @@ import io.getstream.chat.android.client.api2.model.requests.CreatePollRequest import io.getstream.chat.android.client.api2.model.requests.FlagMessageRequest import io.getstream.chat.android.client.api2.model.requests.FlagRequest import io.getstream.chat.android.client.api2.model.requests.FlagUserRequest -import io.getstream.chat.android.client.api2.model.requests.GroupedQueryChannelsRequest import io.getstream.chat.android.client.api2.model.requests.GuestUserRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.InviteMembersRequest @@ -74,6 +73,7 @@ import io.getstream.chat.android.client.api2.model.requests.PollVoteRequest import io.getstream.chat.android.client.api2.model.requests.QueryBannedUsersRequest import io.getstream.chat.android.client.api2.model.requests.QueryDraftMessagesRequest import io.getstream.chat.android.client.api2.model.requests.QueryDraftsRequest +import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollVotesRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollsRequest import io.getstream.chat.android.client.api2.model.requests.QueryReactionsRequest @@ -1318,10 +1318,10 @@ constructor( } } - override fun groupedQueryChannels(limit: Int?, watch: Boolean, presence: Boolean): Call { - val body = GroupedQueryChannelsRequest(limit = limit, watch = watch, presence = presence) + override fun queryGroupedChannels(limit: Int?, watch: Boolean, presence: Boolean): Call { + val body = QueryGroupedChannelsRequest(limit = limit, watch = watch, presence = presence) val lazyCall = { - channelApi.groupedQueryChannels( + channelApi.queryGroupedChannels( connectionId = connectionId, body = body, ).map { response -> @@ -1337,7 +1337,7 @@ constructor( } val isConnectionRequired = watch || presence return if (isConnectionRequired && connectionId.isBlank()) { - logger.i { "[groupedQueryChannels] postponing because an active connection is required" } + logger.i { "[queryGroupedChannels] postponing because an active connection is required" } postponeCall(lazyCall) } else { lazyCall() diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt index 44e59d3eda4..3c8d1c8d0ad 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt @@ -21,7 +21,6 @@ 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.AcceptInviteRequest import io.getstream.chat.android.client.api2.model.requests.AddMembersRequest -import io.getstream.chat.android.client.api2.model.requests.GroupedQueryChannelsRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.InviteMembersRequest import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest @@ -30,6 +29,7 @@ import io.getstream.chat.android.client.api2.model.requests.MarkUnreadRequest import io.getstream.chat.android.client.api2.model.requests.PinnedMessagesRequest import io.getstream.chat.android.client.api2.model.requests.QueryChannelRequest import io.getstream.chat.android.client.api2.model.requests.QueryChannelsRequest +import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsRequest import io.getstream.chat.android.client.api2.model.requests.RejectInviteRequest import io.getstream.chat.android.client.api2.model.requests.RemoveMembersRequest import io.getstream.chat.android.client.api2.model.requests.SendEventRequest @@ -42,9 +42,9 @@ import io.getstream.chat.android.client.api2.model.requests.UpdateMemberPartialR import io.getstream.chat.android.client.api2.model.response.ChannelResponse import io.getstream.chat.android.client.api2.model.response.CompletableResponse import io.getstream.chat.android.client.api2.model.response.EventResponse -import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse import io.getstream.chat.android.client.call.RetrofitCall import retrofit2.http.Body import retrofit2.http.DELETE @@ -71,10 +71,10 @@ internal interface ChannelApi { * @param body The request body containing limit, watch, and presence parameters. */ @POST("/channels/grouped") - fun groupedQueryChannels( + fun queryGroupedChannels( @Query(QueryParams.CONNECTION_ID) connectionId: String, - @Body body: GroupedQueryChannelsRequest, - ): RetrofitCall + @Body body: QueryGroupedChannelsRequest, + ): RetrofitCall @POST("/channels/{type}/query") fun queryChannel( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/GroupedQueryChannelsRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt similarity index 96% rename from stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/GroupedQueryChannelsRequest.kt rename to stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt index 20531026662..bc76fdd709a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/GroupedQueryChannelsRequest.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt @@ -26,7 +26,7 @@ import com.squareup.moshi.JsonClass * @param presence Whether to receive presence events for the members of the returned channels. */ @JsonClass(generateAdapter = true) -internal data class GroupedQueryChannelsRequest( +internal data class QueryGroupedChannelsRequest( val limit: Int?, val watch: Boolean, val presence: Boolean, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt similarity index 85% rename from stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt rename to stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt index e75d815f161..3922aa7635e 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/GroupedQueryChannelsResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt @@ -25,19 +25,19 @@ import com.squareup.moshi.JsonClass * @param duration The server-reported request duration. */ @JsonClass(generateAdapter = true) -internal data class GroupedQueryChannelsResponse( - val groups: Map, +internal data class QueryGroupedChannelsResponse( + val groups: Map, val duration: String, ) /** - * A single group within a [GroupedQueryChannelsResponse]. + * A single group within a [QueryGroupedChannelsResponse]. * * @param channels The channel responses that belong to this group. * @param unread_channels The number of channels with unread messages in this group. */ @JsonClass(generateAdapter = true) -internal data class GroupedQueryChannelsGroup( +internal data class QueryGroupedChannelsGroup( val channels: List, val unread_channels: Int?, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt index 630f6cea3bc..ae58fb94641 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt @@ -32,7 +32,6 @@ import io.getstream.chat.android.client.plugin.listeners.DraftMessageListener import io.getstream.chat.android.client.plugin.listeners.EditMessageListener import io.getstream.chat.android.client.plugin.listeners.FetchCurrentUserListener import io.getstream.chat.android.client.plugin.listeners.GetMessageListener -import io.getstream.chat.android.client.plugin.listeners.GroupedQueryChannelsListener import io.getstream.chat.android.client.plugin.listeners.HideChannelListener import io.getstream.chat.android.client.plugin.listeners.LiveLocationListener import io.getstream.chat.android.client.plugin.listeners.MarkAllReadListener @@ -40,6 +39,7 @@ import io.getstream.chat.android.client.plugin.listeners.PushPreferencesListener import io.getstream.chat.android.client.plugin.listeners.QueryBlockedUsersListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener +import io.getstream.chat.android.client.plugin.listeners.QueryGroupedChannelsListener import io.getstream.chat.android.client.plugin.listeners.QueryMembersListener import io.getstream.chat.android.client.plugin.listeners.QueryThreadsListener import io.getstream.chat.android.client.plugin.listeners.SendAttachmentListener @@ -89,7 +89,7 @@ public interface Plugin : EditMessageListener, QueryChannelListener, QueryChannelsListener, - GroupedQueryChannelsListener, + QueryGroupedChannelsListener, TypingEventListener, HideChannelListener, MarkAllReadListener, @@ -426,7 +426,7 @@ public interface Plugin : /* No-Op */ } - override suspend fun onGroupedQueryChannelsResult( + override suspend fun onQueryGroupedChannelsResult( result: Result, limit: Int?, watch: Boolean, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt similarity index 83% rename from stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener.kt rename to stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt index b8db63c9d5a..1f96a72195d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/GroupedQueryChannelsListener.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt @@ -22,17 +22,17 @@ import io.getstream.result.Result /** * Listener used when querying grouped channels from the backend. */ -public interface GroupedQueryChannelsListener { +public interface QueryGroupedChannelsListener { /** - * Called when the grouped query channels request completes. + * Called when the query grouped channels request completes. * - * @param result The result of the grouped query channels request. + * @param result The result of the query grouped channels request. * @param limit The maximum number of channels per group that was requested. * @param watch Whether watching was requested. * @param presence Whether presence was requested. */ - public suspend fun onGroupedQueryChannelsResult( + public suspend fun onQueryGroupedChannelsResult( result: Result, limit: Int?, watch: Boolean, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt index 028c9390cdb..39270455386 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt @@ -34,12 +34,12 @@ import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.whenever /** - * Tests for the [ChatClient.groupedQueryChannels] endpoint. + * Tests for the [ChatClient.queryGroupedChannels] endpoint. */ internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { @Test - fun groupedQueryChannelsSuccess() = runTest { + fun queryGroupedChannelsSuccess() = runTest { // given val groupedChannels = GroupedChannels( groups = mapOf( @@ -50,33 +50,33 @@ internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { ), ) val sut = Fixture() - .givenGroupedQueryChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall()) + .givenQueryGroupedChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall()) .get() // when - val result = sut.groupedQueryChannels().await() + val result = sut.queryGroupedChannels().await() // then verifySuccess(result, groupedChannels) } @Test - fun groupedQueryChannelsError() = runTest { + fun queryGroupedChannelsError() = runTest { // given val errorCode = positiveRandomInt() val sut = Fixture() - .givenGroupedQueryChannelsResult(RetroError(errorCode).toRetrofitCall()) + .givenQueryGroupedChannelsResult(RetroError(errorCode).toRetrofitCall()) .get() // when - val result = sut.groupedQueryChannels().await() + val result = sut.queryGroupedChannels().await() // then verifyNetworkError(result, errorCode) } internal inner class Fixture { - fun givenGroupedQueryChannelsResult( + fun givenQueryGroupedChannelsResult( result: io.getstream.result.call.Call, ) = apply { - whenever(api.groupedQueryChannels(anyOrNull(), any(), any())).thenReturn(result) + whenever(api.queryGroupedChannels(anyOrNull(), any(), any())).thenReturn(result) } fun get(): ChatClient = chatClient 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 73b04f0e137..9914135e74a 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 @@ -50,7 +50,6 @@ import io.getstream.chat.android.client.api2.model.requests.CreatePollRequest import io.getstream.chat.android.client.api2.model.requests.DeliveredMessageDto import io.getstream.chat.android.client.api2.model.requests.FlagMessageRequest import io.getstream.chat.android.client.api2.model.requests.FlagUserRequest -import io.getstream.chat.android.client.api2.model.requests.GroupedQueryChannelsRequest import io.getstream.chat.android.client.api2.model.requests.GuestUserRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest @@ -65,6 +64,7 @@ import io.getstream.chat.android.client.api2.model.requests.PartialUpdateUsersRe import io.getstream.chat.android.client.api2.model.requests.PinnedMessagesRequest import io.getstream.chat.android.client.api2.model.requests.PollVoteRequest import io.getstream.chat.android.client.api2.model.requests.QueryBannedUsersRequest +import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollVotesRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollsRequest import io.getstream.chat.android.client.api2.model.requests.QueryReactionsRequest @@ -90,7 +90,6 @@ import io.getstream.chat.android.client.api2.model.response.DevicesResponse import io.getstream.chat.android.client.api2.model.response.DraftMessageResponse import io.getstream.chat.android.client.api2.model.response.EventResponse import io.getstream.chat.android.client.api2.model.response.FlagResponse -import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.MuteUserResponse @@ -102,6 +101,7 @@ import io.getstream.chat.android.client.api2.model.response.QueryBannedUsersResp import io.getstream.chat.android.client.api2.model.response.QueryBlockedUsersResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryDraftMessagesResponse +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryMembersResponse import io.getstream.chat.android.client.api2.model.response.QueryPollVotesResponse import io.getstream.chat.android.client.api2.model.response.QueryPollsResponse @@ -1895,14 +1895,14 @@ internal class MoshiChatApiTest { } @ParameterizedTest - @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#groupedQueryChannelsInput") - fun testGroupedQueryChannels( - call: RetrofitCall, + @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#queryGroupedChannelsInput") + fun testQueryGroupedChannels( + call: RetrofitCall, expected: KClass<*>, ) = runTest { // given val api = mock() - whenever(api.groupedQueryChannels(any(), any())).doReturn(call) + whenever(api.queryGroupedChannels(any(), any())).doReturn(call) val sut = Fixture() .withChannelApi(api) .get() @@ -1911,15 +1911,15 @@ internal class MoshiChatApiTest { val connectionId = randomString() val limit = randomInt() sut.setConnection(userId = userId, connectionId = connectionId) - val result = sut.groupedQueryChannels(limit = limit, watch = false, presence = false).await() + val result = sut.queryGroupedChannels(limit = limit, watch = false, presence = false).await() // then - val expectedPayload = GroupedQueryChannelsRequest( + val expectedPayload = QueryGroupedChannelsRequest( limit = limit, watch = false, presence = false, ) result `should be instance of` expected - verify(api, times(1)).groupedQueryChannels(connectionId, expectedPayload) + verify(api, times(1)).queryGroupedChannels(connectionId, expectedPayload) } @ParameterizedTest diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index 23e8d364a9a..b2d5091c8b8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -39,8 +39,6 @@ import io.getstream.chat.android.client.api2.model.response.CompletableResponse import io.getstream.chat.android.client.api2.model.response.DevicesResponse import io.getstream.chat.android.client.api2.model.response.EventResponse import io.getstream.chat.android.client.api2.model.response.FlagResponse -import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsGroup -import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.MuteUserResponse @@ -51,6 +49,8 @@ import io.getstream.chat.android.client.api2.model.response.QueryBannedUsersResp import io.getstream.chat.android.client.api2.model.response.QueryBlockedUsersResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryDraftMessagesResponse +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsGroup +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryMembersResponse import io.getstream.chat.android.client.api2.model.response.QueryPollVotesResponse import io.getstream.chat.android.client.api2.model.response.QueryPollsResponse @@ -441,12 +441,12 @@ internal object MoshiChatApiTestArguments { ) @JvmStatic - fun groupedQueryChannelsInput() = listOf( + fun queryGroupedChannelsInput() = listOf( Arguments.of( RetroSuccess( - GroupedQueryChannelsResponse( + QueryGroupedChannelsResponse( groups = mapOf( - "all-open" to GroupedQueryChannelsGroup( + "all-open" to QueryGroupedChannelsGroup( channels = listOf( ChannelResponse( channel = Mother.randomDownstreamChannelDto(), @@ -465,7 +465,7 @@ internal object MoshiChatApiTestArguments { Result.Success::class, ), Arguments.of( - RetroError(statusCode = 500).toRetrofitCall(), + RetroError(statusCode = 500).toRetrofitCall(), Result.Failure::class, ), ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt similarity index 95% rename from stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt rename to stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt index f9a9f312972..9bf2010a8ec 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GroupedQueryChannelsResponseAdapterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt @@ -16,7 +16,7 @@ package io.getstream.chat.android.client.parser2 -import io.getstream.chat.android.client.api2.model.response.GroupedQueryChannelsResponse +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse import org.intellij.lang.annotations.Language import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -24,9 +24,9 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test /** - * Tests for JSON deserialization of [GroupedQueryChannelsResponse] using Moshi. + * Tests for JSON deserialization of [QueryGroupedChannelsResponse] using Moshi. */ -internal class GroupedQueryChannelsResponseAdapterTest { +internal class QueryGroupedChannelsResponseAdapterTest { private val parser = ParserFactory.createMoshiChatParser() @Language("JSON") @@ -144,7 +144,7 @@ internal class GroupedQueryChannelsResponseAdapterTest { @Test fun `Deserialize grouped query channels response`() { - val response = parser.fromJson(json, GroupedQueryChannelsResponse::class.java) + val response = parser.fromJson(json, QueryGroupedChannelsResponse::class.java) assertEquals("12ms", response.duration) assertEquals(setOf("all-open"), response.groups.keys) @@ -182,7 +182,7 @@ internal class GroupedQueryChannelsResponseAdapterTest { @Test fun `Deserialize default unread counters when missing`() { - val response = parser.fromJson(jsonWithoutUnreadCounters, GroupedQueryChannelsResponse::class.java) + val response = parser.fromJson(jsonWithoutUnreadCounters, QueryGroupedChannelsResponse::class.java) assertEquals("12ms", response.duration) assertEquals(setOf("expired"), response.groups.keys) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 7739de4f09b..2956f0c14c2 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4894,8 +4894,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelIn public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel : androidx/lifecycle/ViewModel { public static final field $stable I - public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;Z)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun deleteConversation (Lio/getstream/chat/android/models/Channel;)V public final fun dismissChannelAction ()V @@ -4913,6 +4913,7 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final fun muteChannel (Lio/getstream/chat/android/models/Channel;)V public final fun performChannelAction (Lio/getstream/chat/android/ui/common/state/channels/actions/ChannelAction;)V public final fun pinChannel (Lio/getstream/chat/android/models/Channel;)V + public final fun prefill (Ljava/util/List;)V public final fun refresh ()V public final fun selectChannel (Lio/getstream/chat/android/models/Channel;)V public final fun setFilters (Lio/getstream/chat/android/models/FilterObject;)V @@ -4927,8 +4928,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public static final field $stable I public fun ()V - public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;Z)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 71e60fcd1bb..aa900a698dc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -45,6 +45,8 @@ import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.extensions.globalStateFlow +import io.getstream.chat.android.state.extensions.initQueryChannelsAsState +import io.getstream.chat.android.state.extensions.prefillQueryChannels import io.getstream.chat.android.state.extensions.queryChannelsAsState import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData @@ -91,6 +93,8 @@ import kotlin.coroutines.cancellation.CancellationException * @param isDraftMessageEnabled If the draft message feature is enabled. * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. * @param globalState A flow emitting the current [GlobalState]. + * @param skipInitialQuery When `true`, the ViewModel will not perform the initial queryChannels API call. + * The channel list state can then be populated via [prefill]. Defaults to `false`. */ @OptIn(ExperimentalCoroutinesApi::class) @Suppress("TooManyFunctions") @@ -106,6 +110,7 @@ public class ChannelListViewModel( private val isDraftMessageEnabled: Boolean = false, private val messageSearchSort: QuerySorter? = null, private val globalState: Flow = chatClient.globalStateFlow, + private val skipInitialQuery: Boolean = false, ) : ViewModel() { private val logger by taggedLogger("Chat:ChannelListVM") @@ -265,9 +270,15 @@ public class ChannelListViewModel( .collectLatest { (query, config, ts) -> logger.i { "[observeInit] ts: $ts, query: $query, config: $config" } when (query) { - is SearchQuery.Empty, - is SearchQuery.Channels, - -> { + is SearchQuery.Empty -> { + searchScope.coroutineContext.cancelChildren() + if (skipInitialQuery) { + observeInitQueryChannels(config) + } else { + observeQueryChannels(config) + } + } + is SearchQuery.Channels -> { searchScope.coroutineContext.cancelChildren() observeQueryChannels( config.copy( @@ -403,8 +414,42 @@ public class ChannelListViewModel( } } + /** + * Creates a [QueryChannelsState] without triggering an API call and starts collecting from it. + * Used when [skipInitialQuery] is `true` — the state can be populated later via [prefill]. + */ + private fun observeInitQueryChannels(config: QueryConfig) = + observeQueryChannelsInternal(config, tag = "observeInitQueryChannels") { request -> + chatClient.initQueryChannelsAsState( + request = request, + chatEventHandlerFactory = chatEventHandlerFactory, + coroutineScope = chListScope, + ) + } + + /** + * Creates a [QueryChannelsState] by triggering an API call and starts collecting from it. + */ + @Suppress("LongMethod") + private fun observeQueryChannels(config: QueryConfig) = + observeQueryChannelsInternal(config, tag = "observeQueryChannels") { request -> + chatClient.queryChannelsAsState( + request = request, + chatEventHandlerFactory = chatEventHandlerFactory, + coroutineScope = chListScope, + ) + } + + /** + * Shared implementation for observing a [QueryChannelsState]. + * The [createState] lambda determines how the state is created (with or without an API call). + */ @Suppress("LongMethod") - private fun observeQueryChannels(config: QueryConfig) = runCatching { + private fun observeQueryChannelsInternal( + config: QueryConfig, + tag: String, + createState: (QueryChannelsRequest) -> StateFlow, + ) = runCatching { queryChannelDebouncer.submitSuspendable { val queryChannelsRequest = QueryChannelsRequest( filter = config.filters, @@ -413,12 +458,7 @@ public class ChannelListViewModel( messageLimit = messageLimit, memberLimit = memberLimit, ) - logger.d { "[observeQueryChannels] request: $queryChannelsRequest" } - queryChannelsState = chatClient.queryChannelsAsState( - request = queryChannelsRequest, - chatEventHandlerFactory = chatEventHandlerFactory, - coroutineScope = chListScope, - ) + queryChannelsState = createState(queryChannelsRequest) queryChannelsState.filterNotNull().collectLatest { queryChannelsState -> combine( queryChannelsState.channelsStateData, @@ -432,10 +472,10 @@ public class ChannelListViewModel( -> channelsState.copy( isLoading = true, searchQuery = _searchQuery.value, - ).also { logger.d { "[observeQueryChannels] state: Loading" } } + ).also { logger.d { "[$tag] state: Loading" } } ChannelsStateData.OfflineNoResults -> { - logger.v { "[observeQueryChannels] state: OfflineNoResults(channels are empty)" } + logger.v { "[$tag] state: OfflineNoResults(channels are empty)" } channelsState.copy( isLoading = false, channelItems = emptyList(), @@ -444,7 +484,7 @@ public class ChannelListViewModel( } is ChannelsStateData.Result -> { - logger.v { "[observeQueryChannels] state: Result(channels.size: ${state.channels.size})" } + logger.v { "[$tag] state: Result(channels.size: ${state.channels.size})" } channelsState.copy( isLoading = false, channelItems = createChannelItems( @@ -464,8 +504,8 @@ public class ChannelListViewModel( } }.onFailure { when (it is CancellationException) { - true -> logger.v { "[observeQueryChannels] cancelled" } - else -> logger.e { "[observeQueryChannels] failed: $it" } + true -> logger.v { "[$tag] cancelled" } + else -> logger.e { "[$tag] failed: $it" } } } @@ -505,6 +545,36 @@ public class ChannelListViewModel( ) } + /** + * Injects fresh channel data into the channel list, replacing any previously loaded data. + * Channels are persisted to the local database for offline recovery. After prefill, + * pagination ([loadMore]) works normally with the correct offset. + * + * Requires [skipInitialQuery] to be `true`. Can be called at any time after ViewModel + * creation — if the state is not yet initialized, the call suspends until it is ready. + * + * @param channels The channels to populate the list with. + */ + public fun prefill(channels: List) { + logger.d { "[prefill] channels.size: ${channels.size}" } + if (!skipInitialQuery) { + logger.w { "[prefill] rejected (skipInitialQuery is false)" } + return + } + chListScope.launch { + val filter = filterFlow.filterNotNull().first() + val sort = querySortFlow.value + val request = QueryChannelsRequest( + filter = filter, + querySort = sort, + limit = channelLimit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + chatClient.prefillQueryChannels(request, channels) + } + } + /** * Refreshes either channels or search results. */ diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index fbd2c9afd3c..fc2106842a3 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -41,6 +41,8 @@ import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandl * When `null`, the server-side default is used. * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. * @param messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. + * @param skipInitialQuery When `true`, the ViewModel will not perform the initial queryChannels API call. + * The channel list state can then be populated via [ChannelListViewModel.prefill]. Defaults to `false`. */ public class ChannelViewModelFactory( private val chatClient: ChatClient = ChatClient.instance(), @@ -52,6 +54,7 @@ public class ChannelViewModelFactory( private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), private val isDraftMessageEnabled: Boolean = false, private val messageSearchSort: QuerySorter? = null, + private val skipInitialQuery: Boolean = false, ) : ViewModelProvider.Factory { /** @@ -72,6 +75,7 @@ public class ChannelViewModelFactory( chatEventHandlerFactory = chatEventHandlerFactory, isDraftMessageEnabled = isDraftMessageEnabled, messageSearchSort = messageSearchSort, + skipInitialQuery = skipInitialQuery, ) as T } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index f9c54285f30..c7bf392ef60 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -527,6 +527,111 @@ internal class ChannelListViewModelTest { assertEquals(messageSearchSort, sortCaptor.firstValue) } + @Test + fun `Given skipInitialQuery is true When ViewModel initializes Should not call queryChannels`() = + runTest { + val chatClient: ChatClient = mock() + Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) + .givenChannelMutes() + .get(this, skipInitialQuery = true) + + verify(chatClient, times(0)).queryChannels(any()) + } + + @Test + fun `Given skipInitialQuery is true When prefill is called Should show prefilled channels`() = + runTest { + val chatClient: ChatClient = mock() + val channelsStateData = MutableStateFlow(ChannelsStateData.Loading) + val loadingFlow = MutableStateFlow(true) + val endOfChannelsFlow = MutableStateFlow(false) + val nextPageRequestFlow = MutableStateFlow(null) + val queryChannelsState: QueryChannelsState = mock { + whenever(it.channelsStateData) doReturn channelsStateData + whenever(it.channels) doReturn MutableStateFlow(null) + whenever(it.loading) doReturn loadingFlow + whenever(it.loadingMore) doReturn MutableStateFlow(false) + whenever(it.endOfChannels) doReturn endOfChannelsFlow + whenever(it.nextPageRequest) doReturn nextPageRequestFlow + } + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState(queryChannelsState) + .givenChannelMutes() + .get(this, skipInitialQuery = true) + + assertTrue(viewModel.channelsState.isLoading) + + // Simulate what prefillQueryChannels does to the state + channelsStateData.value = ChannelsStateData.Result(listOf(channel1, channel2)) + loadingFlow.value = false + advanceUntilIdle() + + assertFalse(viewModel.channelsState.isLoading) + assertEquals(2, viewModel.channelsState.channelItems.size) + verify(chatClient, times(0)).queryChannels(any()) + } + + @Test + fun `Given skipInitialQuery is false When ViewModel initializes Should call queryChannels normally`() = + runTest { + val chatClient: ChatClient = mock() + Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .get(this) + + verify(chatClient, times(1)).queryChannels(any()) + } + + @Test + fun `Given skipInitialQuery is true and prefill not called When showing channels Should show loading state`() = + runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) + .givenChannelMutes() + .get(this, skipInitialQuery = true) + + assertTrue(viewModel.channelsState.isLoading) + assertEquals(0, viewModel.channelsState.channelItems.size) + } + + @Test + fun `Given skipInitialQuery is true When loadMore after prefill Should use nextPageRequest offset`() = + runTest { + val chatClient: ChatClient = mock() + val nextPageRequest = QueryChannelsRequest( + filter = queryFilter, + offset = 20, + limit = 30, + querySort = querySort, + ) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + nextPageRequest = nextPageRequest, + ) + .givenChannelMutes() + .get(this, skipInitialQuery = true) + + viewModel.loadMore() + advanceUntilIdle() + + verify(chatClient).queryChannels(nextPageRequest) + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), @@ -542,8 +647,13 @@ internal class ChannelListViewModelTest { init { val statePlugin: StatePlugin = mock() whenever(globalState.typingChannels) doReturn MutableStateFlow(emptyMap()) - whenever(statePlugin.resolveDependency(eq(StateRegistry::class))) doReturn stateRegistry - whenever(statePlugin.resolveDependency(eq(GlobalState::class))) doReturn globalState + whenever(statePlugin.resolveDependency(any>())).thenAnswer { invocation -> + when (val klass = invocation.getArgument>(0)) { + StateRegistry::class -> stateRegistry + GlobalState::class -> globalState + else -> org.mockito.Mockito.mock(klass.java, org.mockito.Mockito.RETURNS_DEEP_STUBS) + } + } whenever(chatClient.plugins) doReturn listOf(statePlugin) whenever(chatClient.channel(any())) doReturn channelClient whenever(chatClient.channel(any(), any())) doReturn channelClient @@ -619,7 +729,11 @@ internal class ChannelListViewModelTest { whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState } - fun get(testScope: TestScope): ChannelListViewModel { + fun givenChannelsState(queryChannelsState: QueryChannelsState) = apply { + whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState + } + + fun get(testScope: TestScope, skipInitialQuery: Boolean = false): ChannelListViewModel { val channelListViewModel = ChannelListViewModel( chatClient = chatClient, initialSort = initialSort, @@ -628,6 +742,7 @@ internal class ChannelListViewModelTest { chatEventHandlerFactory = ChatEventHandlerFactory(clientState), messageSearchSort = messageSearchSort, globalState = MutableStateFlow(globalState), + skipInitialQuery = skipInitialQuery, ) testScope.advanceUntilIdle() return channelListViewModel diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 9377017dc4c..e11309c590b 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1093,14 +1093,15 @@ public final class io/getstream/chat/android/models/GroupedChannels { } public final class io/getstream/chat/android/models/GroupedChannelsGroup { - public fun (Ljava/util/List;Ljava/lang/Integer;)V + public fun (Ljava/util/List;I)V + public synthetic fun (Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/List; - public final fun component2 ()Ljava/lang/Integer; - public final fun copy (Ljava/util/List;Ljava/lang/Integer;)Lio/getstream/chat/android/models/GroupedChannelsGroup; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/util/List;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public final fun component2 ()I + public final fun copy (Ljava/util/List;I)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/util/List;IILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; public fun equals (Ljava/lang/Object;)Z public final fun getChannels ()Ljava/util/List; - public final fun getUnreadChannels ()Ljava/lang/Integer; + public final fun getUnreadChannels ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt index aa7b1fb1b67..d31035f3f84 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt @@ -17,14 +17,14 @@ package io.getstream.chat.android.models /** - * A grouped channels response returned by [ChatClient.groupedQueryChannels]. + * A grouped channels response returned by [ChatClient.queryGroupedChannels]. * * @param groups The channel groups returned by the backend in response order. */ public data class GroupedChannels(public val groups: Map) /** - * A channel group returned by [ChatClient.groupedQueryChannels]. + * A channel group returned by [ChatClient.queryGroupedChannels]. * * @param channels The channels that belong to this group. * @param unreadChannels The total unread channel count in the group. diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt index 4fb705c3c02..0168d194d63 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt @@ -255,7 +255,14 @@ internal class EventHandlerSequential( logger.v { "[handleChatEvents] batchId: ${batchEvent.id}, batchEvent.size: ${batchEvent.size}" } queryChannelsLogic.parseChatEventResults(batchEvent.sortedEvents).forEach { result -> when (result) { - is EventHandlingResult.Add -> queryChannelsLogic.addChannel(result.channel) + is EventHandlingResult.Add -> { + // Use trackChannel instead of addChannel to avoid overwriting the shared + // per-channel state with a potentially stale DB-cached channel. + // Channel events have already updated per-channel state (e.g., lastMessageAt) + // before this method runs, and refreshChannelsState below will reconcile + // the query map with the live per-channel data. + queryChannelsLogic.trackChannel(result.channel) + } is EventHandlingResult.WatchAndAdd -> queryChannelsLogic.watchAndAddChannel(result.cid) is EventHandlingResult.Remove -> queryChannelsLogic.removeChannel(result.cid) is EventHandlingResult.Skip -> Unit diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index 30bcc85bdd1..91813ceb28b 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -65,6 +65,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach @@ -143,6 +144,53 @@ public fun ChatClient.queryChannelsAsState( } } +/** + * Creates a [QueryChannelsState] for the given [request] without triggering a remote queryChannels + * API call or loading from the local cache. The returned state starts in a loading state and can be + * populated later via [prefillQueryChannels]. + * + * Use this together with [prefillQueryChannels] when channel data is obtained from an external source + * (e.g., a grouped channels endpoint) and the default queryChannels call should be skipped. + * + * @param request The request's parameters combined into [QueryChannelsRequest] class. + * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] that will be used to create [ChatEventHandler]. + * @param coroutineScope The [CoroutineScope] used for executing the request. + * + * @return A StateFlow object that emits a null when the user has not been connected yet and the new [QueryChannelsState] when the user changes. + */ +@InternalStreamChatApi +@JvmOverloads +public fun ChatClient.initQueryChannelsAsState( + request: QueryChannelsRequest, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(clientState), + coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), +): StateFlow { + StreamLog.d(TAG) { "[initQueryChannelsAsState] request: $request" } + return getStateOrNull(coroutineScope) { + requestsAsState(coroutineScope).initQueryChannelsState(request, chatEventHandlerFactory) + } +} + +/** + * Injects [channels] into the [QueryChannelsState] identified by the [request]'s filter and sort. + * The channels replace any existing data and are persisted to the local database. + * No remote API call is made. + * + * The state must have been previously created via [initQueryChannelsAsState] or [queryChannelsAsState]. + * + * @param request The [QueryChannelsRequest] identifying the query to populate. + * @param channels The channels to inject into the state. + */ +@InternalStreamChatApi +public suspend fun ChatClient.prefillQueryChannels( + request: QueryChannelsRequest, + channels: List, +) { + StreamLog.d(TAG) { "[prefillQueryChannels] channels.size: ${channels.size}" } + clientState.user.first { it != null } + logic.queryChannels(request).prefillChannels(channels, request) +} + /** * Performs [ChatClient.queryChannel] with watch = true under the hood and returns [ChannelState] associated with the query. * The [ChannelState] cannot be created before connecting the user therefore, the method returns a StateFlow diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt index 1e0c093f19f..d5f6a5ed595 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt @@ -17,8 +17,21 @@ package io.getstream.chat.android.state.model.querychannels.pagination.internal import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest +/** + * Converts a [QueryChannelsRequest] to an [AnyChannelPaginationRequest] for offline cache lookups. + */ +internal fun QueryChannelsRequest.toOfflinePaginationRequest(): AnyChannelPaginationRequest = + QueryChannelsPaginationRequest( + sort = querySort, + channelLimit = limit, + channelOffset = offset, + messageLimit = messageLimit ?: 10, + memberLimit = memberLimit ?: 30, + ).toAnyChannelPaginationRequest() + internal fun QueryChannelsPaginationRequest.toAnyChannelPaginationRequest(): AnyChannelPaginationRequest { val originalRequest = this return AnyChannelPaginationRequest().apply { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt index 1e148eaa0dd..67edaf4586a 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt @@ -29,7 +29,6 @@ import io.getstream.chat.android.client.plugin.listeners.DeleteReactionListener import io.getstream.chat.android.client.plugin.listeners.DraftMessageListener import io.getstream.chat.android.client.plugin.listeners.EditMessageListener import io.getstream.chat.android.client.plugin.listeners.FetchCurrentUserListener -import io.getstream.chat.android.client.plugin.listeners.GroupedQueryChannelsListener import io.getstream.chat.android.client.plugin.listeners.HideChannelListener import io.getstream.chat.android.client.plugin.listeners.LiveLocationListener import io.getstream.chat.android.client.plugin.listeners.MarkAllReadListener @@ -37,6 +36,7 @@ import io.getstream.chat.android.client.plugin.listeners.PushPreferencesListener import io.getstream.chat.android.client.plugin.listeners.QueryBlockedUsersListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener +import io.getstream.chat.android.client.plugin.listeners.QueryGroupedChannelsListener import io.getstream.chat.android.client.plugin.listeners.QueryMembersListener import io.getstream.chat.android.client.plugin.listeners.QueryThreadsListener import io.getstream.chat.android.client.plugin.listeners.SendAttachmentListener @@ -61,7 +61,6 @@ import io.getstream.chat.android.state.plugin.listener.internal.DeleteReactionLi import io.getstream.chat.android.state.plugin.listener.internal.DraftMessageListenerState import io.getstream.chat.android.state.plugin.listener.internal.EditMessageListenerState import io.getstream.chat.android.state.plugin.listener.internal.FetchCurrentUserListenerState -import io.getstream.chat.android.state.plugin.listener.internal.GroupedQueryChannelsListenerState import io.getstream.chat.android.state.plugin.listener.internal.HideChannelListenerState import io.getstream.chat.android.state.plugin.listener.internal.LiveLocationListenerState import io.getstream.chat.android.state.plugin.listener.internal.MarkAllReadListenerState @@ -69,6 +68,7 @@ import io.getstream.chat.android.state.plugin.listener.internal.PushPreferencesL import io.getstream.chat.android.state.plugin.listener.internal.QueryBlockedUsersListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryChannelListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryChannelsListenerState +import io.getstream.chat.android.state.plugin.listener.internal.QueryGroupedChannelsListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryMembersListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryThreadsListenerState import io.getstream.chat.android.state.plugin.listener.internal.SendAttachmentListenerState @@ -116,7 +116,7 @@ public class StatePlugin internal constructor( ) : Plugin, QueryMembersListener by QueryMembersListenerState(logic), QueryChannelsListener by QueryChannelsListenerState(logic, queryingChannelsFree), - GroupedQueryChannelsListener by GroupedQueryChannelsListenerState(mutableGlobalState), + QueryGroupedChannelsListener by QueryGroupedChannelsListenerState(mutableGlobalState), QueryChannelListener by QueryChannelListenerState(logic), ThreadQueryListener by ThreadQueryListenerState(logic, repositoryFacade), ChannelMarkReadListener by ChannelMarkReadListenerState(stateRegistry), diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt index 4ee12d6c2bc..e667820d2ca 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt @@ -18,10 +18,8 @@ package io.getstream.chat.android.state.plugin.listener.internal import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener -import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.state.model.querychannels.pagination.internal.QueryChannelsPaginationRequest -import io.getstream.chat.android.state.model.querychannels.pagination.internal.toAnyChannelPaginationRequest +import io.getstream.chat.android.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry import io.getstream.result.Result import kotlinx.coroutines.flow.MutableStateFlow @@ -52,7 +50,7 @@ internal class QueryChannelsListenerState( queryingChannelsFree.value = false logicProvider.queryChannels(request).run { setCurrentRequest(request) - queryOffline(request.toPagination()) + queryOffline(request.toOfflinePaginationRequest()) } } @@ -60,16 +58,4 @@ internal class QueryChannelsListenerState( logicProvider.queryChannels(request).onQueryChannelsResult(result, request) queryingChannelsFree.value = true } - - private companion object { - - private fun QueryChannelsRequest.toPagination(): AnyChannelPaginationRequest = - QueryChannelsPaginationRequest( - sort = querySort, - channelLimit = limit, - channelOffset = offset, - messageLimit = messageLimit ?: 10, - memberLimit = memberLimit ?: 30, - ).toAnyChannelPaginationRequest() - } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt similarity index 85% rename from stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt rename to stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt index 60ca6f23dcf..40e3fc5b1f3 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt @@ -16,16 +16,16 @@ package io.getstream.chat.android.state.plugin.listener.internal -import io.getstream.chat.android.client.plugin.listeners.GroupedQueryChannelsListener +import io.getstream.chat.android.client.plugin.listeners.QueryGroupedChannelsListener import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.result.Result -internal class GroupedQueryChannelsListenerState( +internal class QueryGroupedChannelsListenerState( private val globalState: MutableGlobalState, -) : GroupedQueryChannelsListener { +) : QueryGroupedChannelsListener { - override suspend fun onGroupedQueryChannelsResult( + override suspend fun onQueryGroupedChannelsResult( result: Result, limit: Int?, watch: Boolean, diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 2b234a19410..2e0a33c8118 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.log.taggedLogger import io.getstream.result.Result import kotlinx.coroutines.flow.StateFlow @@ -46,6 +47,33 @@ internal class QueryChannelsLogic( private val logger by taggedLogger("Chat:QueryChannelsLogic") + /** + * Sets the current request and optimistically loads any cached channels for the given + * [request] from the local database. The cached channels are added to the in-memory state. + * Does NOT update the channels offset — callers that use this to seed state before a + * [prefillChannels] call can rely on prefill to set the correct offset. + * No remote API call is made. + */ + internal suspend fun loadOfflineChannels(request: QueryChannelsRequest) { + setCurrentRequest(request) + val offlineChannels = fetchChannelsFromCache(request.toOfflinePaginationRequest(), queryChannelsDatabaseLogic) + // fetchChannelsFromCache suspends for DB I/O. During that suspension, a concurrent + // prefillChannels call may have already populated the state. Check after the DB read + // to avoid appending stale offline data on top of fresh prefilled channels. + val existing = queryChannelsStateLogic.getChannels() + if (!existing.isNullOrEmpty()) { + logger.d { "[loadOfflineChannels] skipped (channels already populated: ${existing.size})" } + return + } + if (offlineChannels != null) { + queryChannelsStateLogic.addChannelsState(offlineChannels) + } + // Ensure channels map is non-null (empty if no cache) and loading is reset, so + // channelsStateData transitions to OfflineNoResults instead of staying in Loading. + queryChannelsStateLogic.initializeChannelsIfNeeded() + queryChannelsStateLogic.setLoadingFirstPage(false) + } + internal suspend fun queryOffline(pagination: AnyChannelPaginationRequest) { if (queryChannelsStateLogic.isLoading()) { logger.i { "[queryOffline] another query channels request is in progress. Ignoring this request." } @@ -113,6 +141,16 @@ internal class QueryChannelsLogic( addChannels(listOf(channel)) } + /** + * Registers [channel] in this query's tracking without updating the shared per-channel + * state. Use this during event handling where per-channel state is already authoritative. + * A subsequent [refreshChannelState] / [refreshChannelsState] call will reconcile the + * query map with the live per-channel state. + */ + internal fun trackChannel(channel: Channel) { + queryChannelsStateLogic.trackChannel(channel) + } + /** * Calls watch channel and adds result to the query. * @@ -133,6 +171,49 @@ internal class QueryChannelsLogic( } } + /** + * Replaces the current query's channels with the provided [channels] and persists + * the result to the local database. No remote API call is made. + */ + internal suspend fun prefillChannels(channels: List, request: QueryChannelsRequest) { + logger.d { "[prefillChannels] channels.size: ${channels.size}" } + + // Set current request (needed for nextPageRequest derivation used by loadMore) + queryChannelsStateLogic.setCurrentRequest(request) + + // Remove any existing channels from the state map so addChannelsState + // doesn't merge stale data with the new prefilled channels. + val existingChannels = queryChannelsStateLogic.getChannels() + if (!existingChannels.isNullOrEmpty()) { + queryChannelsStateLogic.removeChannels(existingChannels.keys) + } + + // Clear query spec CIDs (replace semantics) + queryChannelsStateLogic.getQuerySpecs().cids = emptySet() + + // Add channels to in-memory state (also updates per-channel ChannelState via LogicRegistry) + queryChannelsStateLogic.addChannelsState(channels) + + // Set pagination offset = prefilled count + queryChannelsStateLogic.setChannelsOffset(channels.size) + + // endOfChannels only if zero channels (allow pagination otherwise) + queryChannelsStateLogic.setEndOfChannels(channels.isEmpty()) + + // Mark loading complete + queryChannelsStateLogic.setLoadingFirstPage(false) + queryChannelsStateLogic.setLoadingMore(false) + queryChannelsStateLogic.setRecoveryNeeded(false) + + // Persist query spec (cids) to DB + queryChannelsDatabaseLogic.insertQueryChannels(queryChannelsStateLogic.getQuerySpecs()) + + // Persist channel data (configs + channels + messages) to DB + val channelConfigs = channels.map { ChannelConfig(it.type, it.config) } + queryChannelsDatabaseLogic.insertChannelConfigs(channelConfigs) + queryChannelsDatabaseLogic.storeStateForChannels(channels.toSet()) + } + suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { logger.d { "[onQueryChannelsResult] result.isSuccess: ${result is Result.Success}, request: $request" } onOnlineQueryResult(result, request) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index f1d7b9a1812..99fd02e3d02 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -211,6 +211,24 @@ internal class QueryChannelsStateLogic( } } + /** + * Registers the given [channel] in this query's tracking (CID spec + channel map) + * **without** updating the shared per-channel [ChannelState]. + * + * Use this instead of [addChannelsState] when the channel is already active and its + * per-channel state may contain fresher data than the provided [channel] object + * (e.g., during event handling where the channel event handler has already updated + * `lastMessageAt` but the DB-cached channel still has the old value). + * + * A subsequent [refreshChannels] call will pull the authoritative per-channel state + * into the query map. + */ + internal fun trackChannel(channel: Channel) { + mutableState.queryChannelsSpec.cids += channel.cid + val existingChannels = mutableState.rawChannels ?: emptyMap() + mutableState.setChannels(existingChannels + (channel.cid to channel)) + } + /** * Refreshes multiple channels in this query. * Note that it retrieves the data from the current [ChannelState] object. diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt index df8ec2b73b3..88415441f6d 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.internal.logic import io.getstream.chat.android.state.extensions.state import io.getstream.chat.android.state.model.querychannels.pagination.internal.QueryChannelPaginationRequest import io.getstream.chat.android.state.plugin.state.StateRegistry @@ -70,6 +71,27 @@ internal class ChatClientStateCalls( .also { queryChannelsState -> queryChannelsState.chatEventHandlerFactory = chatEventHandlerFactory } } + /** + * Creates or retrieves the [QueryChannelsState] for the given [request] without launching + * a remote queryChannels API call, and optimistically populates it with any channels + * cached in the local database. The state can be further populated later via + * [ChatClient.prefillQueryChannels]. + */ + internal suspend fun initQueryChannelsState( + request: QueryChannelsRequest, + chatEventHandlerFactory: ChatEventHandlerFactory, + ): QueryChannelsState { + logger.d { "[initQueryChannelsState] request: $request" } + chatClient.clientState.user.first { it != null } + chatClient.logic.queryChannels(request).apply { + loadOfflineChannels(request) + } + return deferredState + .await() + .queryChannels(request.filter, request.querySort) + .also { it.chatEventHandlerFactory = chatEventHandlerFactory } + } + /** Reference request of the channel query. */ private suspend fun queryChannel( channelType: String, diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt index 64a33bc7a13..8c38f17bdeb 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt @@ -151,7 +151,7 @@ internal class QueryChannelsMutableState( /** * Set the end of channels. * - * @parami isEnd Boolean + * @param isEnd Boolean */ fun setEndOfChannels(isEnd: Boolean) { _endOfChannels?.value = isEnd diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index 68f2cb6f81c..2bf8129b8a6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -423,6 +423,7 @@ internal class SyncManager( } private suspend fun updateActiveQueryChannels(recoverAll: Boolean): Result> { + // TODO: Exclude ChannelList pre-populated with GroupedQueryChannels // 2. update the results for queries that are actively being shown right now (synchronous) logger.d { "[updateActiveQueryChannels] recoverAll: $recoverAll" } val queryLogicsToRestore = logicRegistry.getActiveQueryChannelsLogic() diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt similarity index 90% rename from stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt rename to stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt index 611e5c494f3..6ab7f41b5d3 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/GroupedQueryChannelsListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt @@ -30,10 +30,10 @@ import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify -internal class GroupedQueryChannelsListenerStateTest { +internal class QueryGroupedChannelsListenerStateTest { private val globalState: MutableGlobalState = mock() - private val listener = GroupedQueryChannelsListenerState(globalState) + private val listener = QueryGroupedChannelsListenerState(globalState) @Test fun `when result is successful, grouped unread channels should be set on global state`() = runTest { @@ -48,7 +48,7 @@ internal class GroupedQueryChannelsListenerStateTest { ), ) // when - listener.onGroupedQueryChannelsResult(result, limit = null, watch = false, presence = false) + listener.onQueryGroupedChannelsResult(result, limit = null, watch = false, presence = false) // then verify(globalState, times(1)).setGroupedUnreadChannels(mapOf("direct" to 3, "support" to 1)) } @@ -65,7 +65,7 @@ internal class GroupedQueryChannelsListenerStateTest { ), ) // when - listener.onGroupedQueryChannelsResult(result, limit = 10, watch = true, presence = false) + listener.onQueryGroupedChannelsResult(result, limit = 10, watch = true, presence = false) // then verify(globalState, times(1)).setGroupedUnreadChannels(mapOf("expired" to 0)) } @@ -75,7 +75,7 @@ internal class GroupedQueryChannelsListenerStateTest { // given val result = Result.Failure(Error.GenericError("Network error")) // when - listener.onGroupedQueryChannelsResult(result, limit = null, watch = false, presence = false) + listener.onQueryGroupedChannelsResult(result, limit = null, watch = false, presence = false) // then verify(globalState, never()).setGroupedUnreadChannels(any()) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 089523a6e7a..681fdab96b3 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -357,4 +357,134 @@ internal class QueryChannelsLogicTest { } // endregion + + // region loadOfflineChannels + + @Test + fun `loadOfflineChannels populates state from cache`() = runTest { + // Given + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + val cachedChannels = listOf(randomChannel(), randomChannel()) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cachedChannels + + // When + logic.loadOfflineChannels(request) + + // Then + verify(queryChannelsStateLogic).setCurrentRequest(request) + verify(queryChannelsStateLogic).addChannelsState(cachedChannels) + verify(queryChannelsStateLogic).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + verify(queryChannelsStateLogic, never()).setChannelsOffset(any()) + } + + @Test + fun `loadOfflineChannels handles null cache gracefully`() = runTest { + // Given + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn null + + // When + logic.loadOfflineChannels(request) + + // Then + verify(queryChannelsStateLogic).setCurrentRequest(request) + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + } + + @Test + fun `loadOfflineChannels skips when channels already populated`() = runTest { + // Given - race condition: channels were populated by a concurrent prefill + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + val existingChannels = mapOf("messaging:ch1" to randomChannel()) + whenever(queryChannelsStateLogic.getChannels()) doReturn existingChannels + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn listOf(randomChannel()) + + // When + logic.loadOfflineChannels(request) + + // Then - only setCurrentRequest should be called, nothing else + verify(queryChannelsStateLogic).setCurrentRequest(request) + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic, never()).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic, never()).setLoadingFirstPage(any()) + } + + // endregion + + // region prefillChannels + + @Test + fun `prefillChannels replaces existing channels and updates all state`() = runTest { + // Given + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + val oldChannel = randomChannel(id = "old") + val existingChannels = mapOf(oldChannel.cid to oldChannel) + val newChannels = listOf(randomChannel(id = "new1"), randomChannel(id = "new2"), randomChannel(id = "new3")) + whenever(queryChannelsStateLogic.getChannels()) doReturn existingChannels + + // When + logic.prefillChannels(newChannels, request) + + // Then + verify(queryChannelsStateLogic).setCurrentRequest(request) + verify(queryChannelsStateLogic).removeChannels(existingChannels.keys) + verify(queryChannelsStateLogic).addChannelsState(newChannels) + verify(queryChannelsStateLogic).setChannelsOffset(3) + verify(queryChannelsStateLogic).setEndOfChannels(false) + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + verify(queryChannelsStateLogic).setLoadingMore(false) + verify(queryChannelsStateLogic).setRecoveryNeeded(false) + } + + @Test + fun `prefillChannels with empty list marks end of channels`() = runTest { + // Given + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + + // When + logic.prefillChannels(emptyList(), request) + + // Then + verify(queryChannelsStateLogic).setChannelsOffset(0) + verify(queryChannelsStateLogic).setEndOfChannels(true) + } + + @Test + fun `prefillChannels skips remove when no existing channels`() = runTest { + // Given + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + val newChannels = listOf(randomChannel()) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + + // When + logic.prefillChannels(newChannels, request) + + // Then + verify(queryChannelsStateLogic, never()).removeChannels(any()) + verify(queryChannelsStateLogic).addChannelsState(newChannels) + } + + @Test + fun `prefillChannels persists to database`() = runTest { + // Given + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + val channels = listOf(randomChannel(), randomChannel()) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + + // When + logic.prefillChannels(channels, request) + + // Then + verify(queryChannelsDatabaseLogic).insertQueryChannels(queryChannelsSpec) + verify(queryChannelsDatabaseLogic).insertChannelConfigs(any()) + verify(queryChannelsDatabaseLogic).storeStateForChannels(channels.toSet()) + } + + // endregion } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt index 4845c0ec1d7..3fc42fccc24 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt @@ -16,26 +16,34 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal +import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.client.extensions.internal.toCid import io.getstream.chat.android.client.query.QueryChannelsSpec +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should contain same` import org.junit.Rule import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -159,4 +167,179 @@ internal class QueryChannelsStateLogicTest { assertNull(result) } + + // region Delegation + + @Test + fun `setLoadingMore delegates to mutableState`() { + queryChannelsStateLogic.setLoadingMore(true) + verify(mutableState).setLoadingMore(true) + } + + @Test + fun `setLoadingFirstPage delegates to mutableState`() { + queryChannelsStateLogic.setLoadingFirstPage(true) + verify(mutableState).setLoadingFirstPage(true) + } + + @Test + fun `setCurrentRequest delegates to mutableState`() { + val request = QueryChannelsRequest(filter = Filters.neutral(), limit = 30) + queryChannelsStateLogic.setCurrentRequest(request) + verify(mutableState).setCurrentRequest(request) + } + + @Test + fun `setEndOfChannels delegates to mutableState`() { + queryChannelsStateLogic.setEndOfChannels(true) + verify(mutableState).setEndOfChannels(true) + } + + @Test + fun `setRecoveryNeeded delegates to mutableState`() { + queryChannelsStateLogic.setRecoveryNeeded(true) + verify(mutableState).setRecoveryNeeded(true) + } + + // endregion + + // region removeChannels + + @Test + fun `removeChannels removes cids from spec and channels from state`() { + val chA = randomChannel(type = "messaging", id = "a") + val chB = randomChannel(type = "messaging", id = "b") + val chC = randomChannel(type = "messaging", id = "c") + val channels = mapOf(chA.cid to chA, chB.cid to chB, chC.cid to chC) + val spec = QueryChannelsSpec(Filters.neutral(), QuerySortByField.descByName("")) + .apply { cids = setOf(chA.cid, chB.cid, chC.cid) } + + whenever(mutableState.rawChannels) doReturn channels + whenever(mutableState.queryChannelsSpec) doReturn spec + + val logic = QueryChannelsStateLogic(mutableState, stateRegistry, logicRegistry, testCoroutines.scope) + logic.removeChannels(setOf(chA.cid, chC.cid)) + + assertEquals(setOf(chB.cid), spec.cids) + verify(mutableState).setChannels(mapOf(chB.cid to chB)) + } + + @Test + fun `removeChannels is no-op when rawChannels is null`() { + whenever(mutableState.rawChannels) doReturn null + + queryChannelsStateLogic.removeChannels(setOf("messaging:x")) + + verify(mutableState, never()).setChannels(any()) + } + + // endregion + + // region initializeChannelsIfNeeded + + @Test + fun `initializeChannelsIfNeeded sets empty map when rawChannels is null`() { + whenever(mutableState.rawChannels) doReturn null + + queryChannelsStateLogic.initializeChannelsIfNeeded() + + verify(mutableState).setChannels(emptyMap()) + } + + @Test + fun `initializeChannelsIfNeeded does not overwrite when rawChannels is already set`() { + val existing = mapOf("messaging:ch" to randomChannel()) + whenever(mutableState.rawChannels) doReturn existing + + queryChannelsStateLogic.initializeChannelsIfNeeded() + + verify(mutableState, never()).setChannels(any()) + } + + // endregion + + // region incrementChannelsOffset + + @Test + fun `incrementChannelsOffset adds size to current offset`() { + whenever(mutableState.channelsOffset) doReturn MutableStateFlow(10) + + queryChannelsStateLogic.incrementChannelsOffset(5) + + verify(mutableState).setChannelsOffset(15) + } + + // endregion + + // region addChannelsState edge cases + + @Test + fun `addChannelsState merges messages from existing channels`() = runTest { + val msg1 = randomMessage(id = "m1") + val msg2 = randomMessage(id = "m2") + val channelType = "messaging" + val channelId = "ch1" + val cid = "$channelType:$channelId" + val existingChannel = randomChannel(type = channelType, id = channelId).copy(messages = listOf(msg1)) + val newChannel = existingChannel.copy(messages = listOf(msg2)) + whenever(mutableState.rawChannels) doReturn mapOf(cid to existingChannel) + + queryChannelsStateLogic.addChannelsState(listOf(newChannel)) + + val captor = argumentCaptor>() + verify(mutableState).setChannels(captor.capture()) + val merged = captor.firstValue[cid]!! + val messageIds = merged.messages.map { it.id }.toSet() + assertTrue(messageIds.contains("m1")) + assertTrue(messageIds.contains("m2")) + } + + @Test + fun `addChannelsState deduplicates messages by id`() = runTest { + val sharedMsg = randomMessage(id = "shared") + val channelType = "messaging" + val channelId = "ch1" + val cid = "$channelType:$channelId" + val existingChannel = randomChannel(type = channelType, id = channelId).copy(messages = listOf(sharedMsg)) + val newChannel = existingChannel.copy(messages = listOf(sharedMsg.copy(text = "updated"))) + whenever(mutableState.rawChannels) doReturn mapOf(cid to existingChannel) + + queryChannelsStateLogic.addChannelsState(listOf(newChannel)) + + val captor = argumentCaptor>() + verify(mutableState).setChannels(captor.capture()) + val merged = captor.firstValue[cid]!! + assertEquals(1, merged.messages.count { it.id == "shared" }) + } + + @Test + fun `addChannelsState merges members when total does not exceed memberCount`() = runTest { + val userA = randomUser(id = "userA") + val userB = randomUser(id = "userB") + val memberA = randomMember(user = userA) + val memberB = randomMember(user = userB) + val channelType = "messaging" + val channelId = "ch1" + val cid = "$channelType:$channelId" + val existingChannel = randomChannel(type = channelType, id = channelId).copy( + members = listOf(memberA), + memberCount = 10, + ) + val newChannel = existingChannel.copy( + members = listOf(memberB), + memberCount = 10, + ) + whenever(mutableState.rawChannels) doReturn mapOf(cid to existingChannel) + + queryChannelsStateLogic.addChannelsState(listOf(newChannel)) + + val captor = argumentCaptor>() + verify(mutableState).setChannels(captor.capture()) + val merged = captor.firstValue[cid]!! + val memberUserIds = merged.members.map { it.getUserId() }.toSet() + assertTrue(memberUserIds.contains("userA")) + assertTrue(memberUserIds.contains("userB")) + } + + // endregion } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt new file mode 100644 index 00000000000..d3a389aef0a --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt @@ -0,0 +1,152 @@ +/* + * 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.state.plugin.state.internal + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.plugin.internal.StatePlugin +import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic +import io.getstream.chat.android.state.plugin.state.StateRegistry +import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ChatClientStateCallsTest { + + @get:Rule + val testCoroutines = TestCoroutineRule() + + private lateinit var chatClient: ChatClient + private lateinit var clientState: ClientState + private lateinit var stateRegistry: StateRegistry + private lateinit var logicRegistry: LogicRegistry + private lateinit var queryChannelsLogic: QueryChannelsLogic + private lateinit var queryChannelsState: QueryChannelsState + private lateinit var chatClientStateCalls: ChatClientStateCalls + + private val userFlow = MutableStateFlow(null) + private val filter = Filters.eq("type", "messaging") + private val sort = QuerySortByField.descByName("last_message_at") + private val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + + @BeforeEach + fun setUp() { + clientState = mock { + on(it.user) doReturn userFlow + } + queryChannelsState = mock() + stateRegistry = mock { + on(it.queryChannels(any(), any())) doReturn queryChannelsState + } + queryChannelsLogic = mock() + logicRegistry = mock { + on(it.queryChannels(any())) doReturn queryChannelsLogic + } + + val statePlugin: StatePlugin = mock { + on(it.resolveDependency(eq(StateRegistry::class))) doReturn stateRegistry + on(it.resolveDependency(eq(LogicRegistry::class))) doReturn logicRegistry + } + + chatClient = mock { + on(it.plugins) doReturn listOf(statePlugin) + on(it.clientState) doReturn clientState + on(it.awaitInitializationState(any())) doReturn InitializationState.COMPLETE + } + + chatClientStateCalls = ChatClientStateCalls(chatClient, testCoroutines.scope) + } + + @Test + fun `initQueryChannelsState creates state without API call and configures it`() = runTest { + // Given - user is connected + userFlow.value = User(id = "test-user") + val factory = ChatEventHandlerFactory(clientState) + + // When + val result = chatClientStateCalls.initQueryChannelsState(request, factory) + + // Then + verify(queryChannelsLogic).loadOfflineChannels(request) + verify(chatClient, never()).queryChannels(any()) + assertNotNull(result) + } + + @Test + fun `initQueryChannelsState waits for user before proceeding`() = runTest { + // Given - user is NOT connected yet + val factory = ChatEventHandlerFactory(clientState) + var completed = false + + // When - launch initQueryChannelsState (it should suspend waiting for user) + val job = launch { + chatClientStateCalls.initQueryChannelsState(request, factory) + completed = true + } + advanceUntilIdle() + + // Then - should not have completed yet + assertEquals(false, completed) + verify(queryChannelsLogic, never()).loadOfflineChannels(any()) + + // When - user connects + userFlow.value = User(id = "test-user") + advanceUntilIdle() + + // Then - should complete now + assertEquals(true, completed) + verify(queryChannelsLogic).loadOfflineChannels(request) + job.cancel() + } + + @Test + fun `initQueryChannelsState returns state matching request filter and sort`() = runTest { + // Given + userFlow.value = User(id = "test-user") + val factory = ChatEventHandlerFactory(clientState) + + // When + chatClientStateCalls.initQueryChannelsState(request, factory) + + // Then - stateRegistry.queryChannels should be called with the request's filter and sort + verify(stateRegistry).queryChannels(filter, sort) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt new file mode 100644 index 00000000000..0df21b37520 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt @@ -0,0 +1,256 @@ +/* + * 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.state.plugin.state.querychannels.internal + +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.Location +import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData +import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +internal class QueryChannelsMutableStateTest { + + @get:Rule + val testCoroutines = TestCoroutineRule() + + private val filter = Filters.eq("type", "messaging") + private val sort = QuerySortByField.descByName("last_message_at") + private val latestUsers = MutableStateFlow>(emptyMap()) + private val activeLiveLocations = MutableStateFlow>(emptyList()) + + private lateinit var state: QueryChannelsMutableState + + @BeforeEach + fun setUp() { + state = QueryChannelsMutableState( + filter = filter, + sort = sort, + scope = testCoroutines.scope, + latestUsers = latestUsers, + activeLiveLocations = activeLiveLocations, + ) + } + + // region Setters + + @Test + fun `setLoadingMore updates loadingMore flow`() = runTest { + assertFalse(state.loadingMore.value) + state.setLoadingMore(true) + assertTrue(state.loadingMore.value) + } + + @Test + fun `setLoadingFirstPage updates loading flow`() = runTest { + assertFalse(state.loading.value) + state.setLoadingFirstPage(true) + assertTrue(state.loading.value) + } + + @Test + fun `setCurrentRequest updates currentRequest flow`() = runTest { + assertNull(state.currentRequest.value) + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + state.setCurrentRequest(request) + assertEquals(request, state.currentRequest.value) + } + + @Test + fun `setEndOfChannels updates endOfChannels flow`() = runTest { + assertFalse(state.endOfChannels.value) + state.setEndOfChannels(true) + assertTrue(state.endOfChannels.value) + } + + @Test + fun `setRecoveryNeeded updates recoveryNeeded flow`() = runTest { + assertFalse(state.recoveryNeeded.value) + state.setRecoveryNeeded(true) + assertTrue(state.recoveryNeeded.value) + } + + @Test + fun `setChannelsOffset updates channelsOffset flow`() = runTest { + assertEquals(0, state.channelsOffset.value) + state.setChannelsOffset(42) + assertEquals(42, state.channelsOffset.value) + } + + @Test + fun `setChannels updates rawChannels and channels flow`() = runTest { + assertNull(state.rawChannels) + val channel = randomChannel() + state.setChannels(mapOf(channel.cid to channel)) + assertEquals(mapOf(channel.cid to channel), state.rawChannels) + advanceUntilIdle() + assertEquals(1, state.channels.value?.size) + } + + // endregion + + // region channelsStateData + + @Test + fun `channelsStateData emits Loading when loading is true`() = runTest { + state.setLoadingFirstPage(true) + advanceUntilIdle() + assertEquals(ChannelsStateData.Loading, state.channelsStateData.value) + } + + @Test + fun `channelsStateData emits Loading when channels are null`() = runTest { + // channels are null by default (never set), loading is false + state.setLoadingFirstPage(false) + advanceUntilIdle() + assertEquals(ChannelsStateData.Loading, state.channelsStateData.value) + } + + @Test + fun `channelsStateData emits OfflineNoResults when not loading and channels empty`() = runTest { + state.setChannels(emptyMap()) + state.setLoadingFirstPage(false) + advanceUntilIdle() + assertEquals(ChannelsStateData.OfflineNoResults, state.channelsStateData.value) + } + + @Test + fun `channelsStateData emits Result when channels available`() = runTest { + val channel = randomChannel() + state.setChannels(mapOf(channel.cid to channel)) + state.setLoadingFirstPage(false) + advanceUntilIdle() + val result = state.channelsStateData.value + assertTrue(result is ChannelsStateData.Result) + assertEquals(1, (result as ChannelsStateData.Result).channels.size) + } + + // endregion + + // region nextPageRequest + + @Test + fun `nextPageRequest is null when currentRequest is null`() = runTest { + advanceUntilIdle() + assertNull(state.nextPageRequest.value) + } + + @Test + fun `nextPageRequest combines currentRequest with channelsOffset and updates when offset changes`() = runTest { + val request = QueryChannelsRequest(filter = filter, offset = 0, limit = 30, querySort = sort) + state.setCurrentRequest(request) + state.setChannelsOffset(30) + advanceUntilIdle() + + val nextPage = state.nextPageRequest.value + assertEquals(30, nextPage?.offset) + + state.setChannelsOffset(60) + advanceUntilIdle() + assertEquals(60, state.nextPageRequest.value?.offset) + } + + // endregion + + // region currentLoading + + @Test + fun `currentLoading returns loading when channels are null or empty`() = runTest { + // channels null by default + state.setLoadingFirstPage(true) + assertTrue(state.currentLoading.value) + } + + @Test + fun `currentLoading returns loadingMore when channels are non-empty`() = runTest { + val channel = randomChannel() + state.setChannels(mapOf(channel.cid to channel)) + advanceUntilIdle() + state.setLoadingMore(true) + assertTrue(state.currentLoading.value) + } + + // endregion + + // region sortedChannels + + @Test + fun `channels returns sorted list per sort comparator`() = runTest { + val older = randomChannel(id = "older").copy(lastMessageAt = Date(1000)) + val newer = randomChannel(id = "newer").copy(lastMessageAt = Date(2000)) + // Sort is descByName("last_message_at"), so newer should come first + state.setChannels(mapOf(older.cid to older, newer.cid to newer)) + advanceUntilIdle() + val channels = state.channels.value!! + assertEquals(2, channels.size) + assertEquals(newer.cid, channels[0].cid) + assertEquals(older.cid, channels[1].cid) + } + + @Test + fun `channels update when latestUsers flow changes`() = runTest { + val user = randomUser(id = "user1", name = "Original") + val channel = randomChannel(id = "ch1").copy( + createdBy = user, + ) + state.setChannels(mapOf(channel.cid to channel)) + advanceUntilIdle() + + val updatedUser = user.copy(name = "Updated") + latestUsers.value = mapOf(updatedUser.id to updatedUser) + advanceUntilIdle() + + val result = state.channels.value!!.first() + assertEquals("Updated", result.createdBy.name) + } + + // endregion + + // region destroy + + @Test + fun `destroy nullifies flows and setters become no-ops`() = runTest { + state.destroy() + // After destroy, setters should not throw (they use ?. safe calls) + state.setLoadingMore(true) + state.setLoadingFirstPage(true) + state.setEndOfChannels(true) + state.setRecoveryNeeded(true) + state.setChannelsOffset(99) + // rawChannels should be null since _channels was nullified + assertNull(state.rawChannels) + } + + // endregion +} From 0d4b140be26d0dea90234e1a0245aa7a78db7610 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 23 Apr 2026 17:01:46 +0200 Subject: [PATCH 09/21] Fix ChatEventHandler instantiation race condition. --- .../plugin/state/internal/ChatClientStateCalls.kt | 9 +++++---- .../internal/QueryChannelsMutableState.kt | 14 ++++++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt index 88415441f6d..1181a47d90e 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt @@ -83,13 +83,14 @@ internal class ChatClientStateCalls( ): QueryChannelsState { logger.d { "[initQueryChannelsState] request: $request" } chatClient.clientState.user.first { it != null } + val state = deferredState + .await() + .queryChannels(request.filter, request.querySort) + .apply { this.chatEventHandlerFactory = chatEventHandlerFactory } chatClient.logic.queryChannels(request).apply { loadOfflineChannels(request) } - return deferredState - .await() - .queryChannels(request.filter, request.querySort) - .also { it.chatEventHandlerFactory = chatEventHandlerFactory } + return state } /** Reference request of the channel query. */ diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt index 8c38f17bdeb..d3fbf417ff4 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt @@ -31,7 +31,6 @@ import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState -import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -48,8 +47,6 @@ internal class QueryChannelsMutableState( activeLiveLocations: StateFlow>, ) : QueryChannelsState { - private val logger by taggedLogger("Chat:QueryChannelsState") - internal var rawChannels: Map? get() = _channels?.value private set(value) { @@ -91,16 +88,21 @@ internal class QueryChannelsMutableState( internal val channelsOffset: StateFlow = _channelsOffset!! override var chatEventHandlerFactory: ChatEventHandlerFactory? = null + set(value) { + field = value + _eventHandler = value?.chatEventHandler(mapChannels) + } override val recoveryNeeded: StateFlow = _recoveryNeeded!! /** * Non-nullable property of [ChatEventHandler] to ensure we always have some handler to handle events. Returns * handler set by user or default one if there is no. + * Re-created when [chatEventHandlerFactory] changes. */ - private val eventHandler: ChatEventHandler by lazy { - (chatEventHandlerFactory ?: ChatEventHandlerFactory()).chatEventHandler(mapChannels) - } + private var _eventHandler: ChatEventHandler? = null + private val eventHandler: ChatEventHandler + get() = _eventHandler ?: ChatEventHandlerFactory().chatEventHandler(mapChannels) fun handleChatEvent(event: ChatEvent, cachedChannel: Channel?): EventHandlingResult { return eventHandler.handleChatEvent(event, filter, cachedChannel) From 3def460783e2924cf74bd900e9c04e75357f08db Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 24 Apr 2026 11:39:00 +0200 Subject: [PATCH 10/21] Add sync process for GroupedQueryChannels. --- .../api/stream-chat-android-compose.api | 2 +- .../channels/ChannelListViewModel.kt | 16 +- .../channels/ChannelListViewModelTest.kt | 33 ---- .../android/state/extensions/ChatClient.kt | 6 +- .../internal/QueryChannelsLogic.kt | 16 +- .../internal/QueryChannelsStateLogic.kt | 6 + .../internal/QueryChannelsMutableState.kt | 8 + .../state/sync/internal/SyncManager.kt | 87 +++++++-- .../android/state/internal/SyncManagerTest.kt | 173 ++++++++++++++++++ .../internal/QueryChannelsLogicTest.kt | 9 +- 10 files changed, 293 insertions(+), 63 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 2956f0c14c2..6f293bbfc4c 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4913,7 +4913,7 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final fun muteChannel (Lio/getstream/chat/android/models/Channel;)V public final fun performChannelAction (Lio/getstream/chat/android/ui/common/state/channels/actions/ChannelAction;)V public final fun pinChannel (Lio/getstream/chat/android/models/Channel;)V - public final fun prefill (Ljava/util/List;)V + public final fun prefill (Ljava/util/List;Ljava/lang/String;)V public final fun refresh ()V public final fun selectChannel (Lio/getstream/chat/android/models/Channel;)V public final fun setFilters (Lio/getstream/chat/android/models/FilterObject;)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index aa900a698dc..453ebdac1eb 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -242,8 +242,6 @@ public class ChannelListViewModel( */ private val searchMessageState: MutableStateFlow = MutableStateFlow(null) - private var lastNextQuery: QueryChannelsRequest? = null - /** * Combines the latest search query and filter to fetch channels and emit them to the UI. */ @@ -554,9 +552,12 @@ public class ChannelListViewModel( * creation — if the state is not yet initialized, the call suspends until it is ready. * * @param channels The channels to populate the list with. + * @param groupKey Optional key from [io.getstream.chat.android.models.GroupedChannels.groups] + * that identifies which group this list belongs to. When set, the SDK will automatically + * call `queryGroupedChannels` instead of `queryChannels` during WebSocket reconnect. */ - public fun prefill(channels: List) { - logger.d { "[prefill] channels.size: ${channels.size}" } + public fun prefill(channels: List, groupKey: String) { + logger.d { "[prefill] channels.size: ${channels.size}, groupKey: $groupKey" } if (!skipInitialQuery) { logger.w { "[prefill] rejected (skipInitialQuery is false)" } return @@ -571,7 +572,7 @@ public class ChannelListViewModel( messageLimit = messageLimit, memberLimit = memberLimit, ) - chatClient.prefillQueryChannels(request, channels) + chatClient.prefillQueryChannels(request, channels, groupKey) } } @@ -677,11 +678,6 @@ public class ChannelListViewModel( filter = createQueryChannelsFilter(currentFilter, _searchQuery.value.query), querySort = querySortFlow.value, ) - if (lastNextQuery == nextQuery) { - logger.v { "[loadMoreQueryChannels] rejected (same query)" } - return - } - lastNextQuery = nextQuery logger.v { "[loadMoreQueryChannels] offset: ${nextQuery.offset}, limit: ${nextQuery.limit}" } channelsState = channelsState.copy(isLoadingMore = true) val result = chatClient.queryChannels(nextQuery).await() diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index c7bf392ef60..bb6709139b6 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -314,39 +314,6 @@ internal class ChannelListViewModelTest { assertEquals("Search query", autoCompleteFilterObject.value) } - @Test - fun `Given channel list in content state and the current user is online When loading more channels Should filter out duplicate calls`() = - runTest { - val nextPageRequest = QueryChannelsRequest( - filter = queryFilter, - querySort = querySort, - offset = 30, - limit = 60, - ) - val chatClient: ChatClient = mock() - val viewModel = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - nextPageRequest = nextPageRequest, - loading = false, - ) - .givenChannelMutes() - .givenIsOffline(false) - .get(this) - - viewModel.loadMore() - viewModel.loadMore() - viewModel.loadMore() - - val captor = argumentCaptor() - verify(chatClient, times(2)).queryChannels(captor.capture()) - assertEquals(2, captor.allValues.size) - assertEquals(0, captor.firstValue.offset) - assertEquals(30, captor.secondValue.offset) - } - @Test fun `Given channel list When setting message search query Should search messages without offset or cursor`() = runTest { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index 91813ceb28b..702da3d536f 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -180,15 +180,17 @@ public fun ChatClient.initQueryChannelsAsState( * * @param request The [QueryChannelsRequest] identifying the query to populate. * @param channels The channels to inject into the state. + * @param groupKey The key identifying the group. */ @InternalStreamChatApi public suspend fun ChatClient.prefillQueryChannels( request: QueryChannelsRequest, channels: List, + groupKey: String, ) { - StreamLog.d(TAG) { "[prefillQueryChannels] channels.size: ${channels.size}" } + StreamLog.d(TAG) { "[prefillQueryChannels] channels.size: ${channels.size}, groupKey: $groupKey" } clientState.user.first { it != null } - logic.queryChannels(request).prefillChannels(channels, request) + logic.queryChannels(request).prefillChannels(channels, request, groupKey) } /** diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 2e0a33c8118..bd939825088 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -110,6 +110,10 @@ internal class QueryChannelsLogic( internal fun filter(): FilterObject = filter + internal fun groupKey(): String? = queryChannelsStateLogic.getGroupKey() + + internal fun currentRequest(): QueryChannelsRequest? = queryChannelsStateLogic.getState().currentRequest.value + internal fun recoveryNeeded(): StateFlow { return queryChannelsStateLogic.getState().recoveryNeeded } @@ -175,8 +179,16 @@ internal class QueryChannelsLogic( * Replaces the current query's channels with the provided [channels] and persists * the result to the local database. No remote API call is made. */ - internal suspend fun prefillChannels(channels: List, request: QueryChannelsRequest) { - logger.d { "[prefillChannels] channels.size: ${channels.size}" } + internal suspend fun prefillChannels( + channels: List, + request: QueryChannelsRequest, + groupKey: String, + ) { + logger.d { "[prefillChannels] channels.size: ${channels.size}, groupKey: $groupKey" } + + // Store the group key so SyncManager can route this query through + // queryGroupedChannels instead of individual queryChannels on reconnect. + queryChannelsStateLogic.setGroupKey(groupKey) // Set current request (needed for nextPageRequest derivation used by loadMore) queryChannelsStateLogic.setCurrentRequest(request) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index 99fd02e3d02..d8d3da25e96 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -124,6 +124,12 @@ internal class QueryChannelsStateLogic( mutableState.setChannelsOffset(offset) } + internal fun getGroupKey(): String? = mutableState.groupKey + + internal fun setGroupKey(key: String?) { + mutableState.groupKey = key + } + /** * Increments the channels offset. * diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt index d3fbf417ff4..538a3bb3e80 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt @@ -87,6 +87,13 @@ internal class QueryChannelsMutableState( private var _channelsOffset: MutableStateFlow? = MutableStateFlow(0) internal val channelsOffset: StateFlow = _channelsOffset!! + /** + * The group key from [io.getstream.chat.android.models.GroupedChannels] that this query + * was populated from. Non-null means this query uses grouped channels for sync instead + * of individual queryChannels. + */ + internal var groupKey: String? = null + override var chatEventHandlerFactory: ChatEventHandlerFactory? = null set(value) { field = value @@ -189,6 +196,7 @@ internal class QueryChannelsMutableState( _currentRequest = null _recoveryNeeded = null _channelsOffset = null + groupKey = null } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index 2bf8129b8a6..723cb5e59af 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -49,6 +49,7 @@ import io.getstream.chat.android.models.Reaction import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.TimeDuration import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.log.taggedLogger @@ -83,7 +84,7 @@ private const val QUERIES_TO_RETRY = 3 * This class is responsible to sync messages, reactions and channel data. It tries to sync then, if necessary, * when connection is reestablished or when a health check event happens. */ -@Suppress("LongParameterList", "TooManyFunctions", "TooGenericExceptionCaught") +@Suppress("LongParameterList", "TooManyFunctions", "TooGenericExceptionCaught", "LargeClass") internal class SyncManager( private val currentUserId: String, private val chatClient: ChatClient, @@ -405,29 +406,93 @@ internal class SyncManager( private suspend fun restoreActiveChannels() { val recoverAll = !isFirstConnect.compareAndSet(true, false) logger.d { "[restoreActiveChannels] recoverAll: $recoverAll" } - when (val result = updateActiveQueryChannels(recoverAll)) { + + // 1. Refresh grouped query channels (prefilled ones) via a single queryGroupedChannels call. + val groupedCids = updateGroupedQueryChannels(recoverAll) + + // 2. Refresh standard (non-grouped) query channels via individual queryFirstPage calls. + val standardCids = when (val result = updateActiveQueryChannels(recoverAll)) { is Result.Success -> { - val updatedCids = result.value - logger.v { "[restoreActiveChannels] updatedCids.size: ${updatedCids.size}" } - updateActiveChannels( - recoverAll, - updatedCids, - ) + logger.v { "[restoreActiveChannels] standardCids.size: ${result.value.size}" } + result.value } + is Result.Failure -> { + logger.e { "[restoreActiveChannels] standard query failed: ${result.value}" } + emptySet() + } + } + + // 3. Re-watch individual channels not covered by steps 1 or 2. + updateActiveChannels(recoverAll, groupedCids + standardCids) + } + + /** + * For [QueryChannelsLogic] instances populated via grouped channels ([prefill][QueryChannelsLogic.prefillChannels]), + * calls [ChatClient.queryGroupedChannels] once and re-prefills each with fresh data. + * + * @return The union of all CIDs from both the old state and the new grouped response, + * so they can be excluded from individual channel re-watches. + */ + private suspend fun updateGroupedQueryChannels(recoverAll: Boolean): Set { + val groupedLogics = logicRegistry.getActiveQueryChannelsLogic() + .filter { it.groupKey() != null } + .filter { it.recoveryNeeded().value || recoverAll } + if (groupedLogics.isEmpty()) { + logger.v { "[updateGroupedQueryChannels] no grouped queries to restore" } + return emptySet() + } + logger.d { "[updateGroupedQueryChannels] groupedLogics.size: ${groupedLogics.size}" } + + // Collect ALL active ChannelLogic CIDs (not just queryChannelsSpec.cids) because + // ChannelState entries persist in the StateRegistry even after prefillChannels resets + // the query's tracking set on previous reconnects. + val oldCids = logicRegistry.getActiveChannelsLogic().map { it.cid }.toMutableSet() + val groupKeyToLogic = mutableMapOf() + groupedLogics.forEach { logic -> + val key = logic.groupKey() ?: return@forEach + groupKeyToLogic[key] = logic + } + logger.d { "[updateGroupedQueryChannels] oldCids.size: ${oldCids.size}" } + + val result = chatClient.queryGroupedChannels().await() + + return when (result) { + is Result.Success -> { + val newCids = mutableSetOf() + val grouped = result.value + + groupKeyToLogic.forEach { (key, logic) -> + val channels = grouped.groups[key]?.channels ?: emptyList() + newCids.addAll(channels.map { it.cid }) + + val currentRequest = logic.currentRequest() + if (currentRequest != null) { + logic.prefillChannels(channels, currentRequest, key) + } else { + logger.w { + "[updateGroupedQueryChannels] no current request for group '$key', skipping prefill" + } + } + } + logger.v { + "[updateGroupedQueryChannels] succeeded; oldCids=${oldCids.size}, newCids=${newCids.size}" + } + oldCids + newCids + } is Result.Failure -> { - logger.e { "[restoreActiveChannels] failed: ${result.value}" } - return + logger.e { "[updateGroupedQueryChannels] queryGroupedChannels failed: ${result.value}" } + oldCids } } } private suspend fun updateActiveQueryChannels(recoverAll: Boolean): Result> { - // TODO: Exclude ChannelList pre-populated with GroupedQueryChannels // 2. update the results for queries that are actively being shown right now (synchronous) logger.d { "[updateActiveQueryChannels] recoverAll: $recoverAll" } val queryLogicsToRestore = logicRegistry.getActiveQueryChannelsLogic() .asSequence() + .filter { it.groupKey() == null } .filter { queryChannelsLogic -> queryChannelsLogic.recoveryNeeded().value || recoverAll } .take(QUERIES_TO_RETRY) .toList() diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt index c1a0d8fd7a6..0ed06318878 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt @@ -31,6 +31,8 @@ import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.observable.Disposable import io.getstream.chat.android.core.internal.coroutines.Tube import io.getstream.chat.android.models.ConnectionState +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.TimeDuration @@ -45,6 +47,7 @@ import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.state.sync.internal.SyncManager @@ -518,6 +521,176 @@ internal class SyncManagerTest { verify(chatClient, never()).getSyncHistory(any(), any()) } + // region Grouped Channels Sync + + @Test + fun `on reconnect should call queryGroupedChannels instead of queryFirstPage for prefilled queries`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val channelA = randomChannel(type = "messaging", id = "a") + val channelB = randomChannel(type = "messaging", id = "b") + + val queryLogic: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( + Result.Success( + GroupedChannels( + groups = mapOf("all" to GroupedChannelsGroup(channels = listOf(channelA, channelB))), + ), + ), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + + // First event marks isFirstConnect=false + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + // Second event triggers reconnect with recoverAll=true + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + verify(chatClient).queryGroupedChannels() + verify(queryLogic, never()).queryFirstPage() + } + + @Test + fun `on reconnect grouped CIDs should be excluded from updateActiveChannels`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val channelA = randomChannel(type = "messaging", id = "a") + + val channelLogicA: ChannelLogic = mock { on(it.cid) doReturn "messaging:a" } + val channelLogicB: ChannelLogic = mock { on(it.cid) doReturn "messaging:b" } + + val queryLogic: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn listOf(channelLogicA, channelLogicB) + whenever(stateRegistry.getActiveChannelStates()) doReturn emptyList() + whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( + Result.Success( + GroupedChannels( + groups = mapOf("all" to GroupedChannelsGroup(channels = listOf(channelA))), + ), + ), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + // queryChannelsInternal should NOT be called because all active CIDs + // (messaging:a, messaging:b) are excluded via the grouped oldCids set. + verify(chatClient, never()).queryChannelsInternal(any()) + } + + @Test + fun `on reconnect with grouped query failure should still exclude old CIDs`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val channelLogicA: ChannelLogic = mock { on(it.cid) doReturn "messaging:a" } + + val queryLogic: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn listOf(channelLogicA) + whenever(stateRegistry.getActiveChannelStates()) doReturn emptyList() + whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( + Result.Failure( + Error.NetworkError(message = "fail", serverErrorCode = 0, statusCode = 500), + ), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + // Even on failure, old CIDs should prevent updateActiveChannels from re-watching + verify(chatClient, never()).queryChannelsInternal(any()) + } + + @Test + fun `updateActiveQueryChannels should skip grouped queries`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val groupedQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(true) + } + + val standardQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn null + on(it.recoveryNeeded()) doReturn MutableStateFlow(true) + onBlocking { it.queryFirstPage() } doReturn Result.Success(emptyList()) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(groupedQuery, standardQuery) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(stateRegistry.getActiveChannelStates()) doReturn emptyList() + whenever(chatClient.queryGroupedChannels()) doReturn TestCall( + Result.Success(GroupedChannels(groups = emptyMap())), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + // Standard query should have queryFirstPage called (once per connect event) + verify(standardQuery, times(2)).queryFirstPage() + // Grouped query should NOT have queryFirstPage called + verify(groupedQuery, never()).queryFirstPage() + } + + private fun connectedEvent(createdAt: Date, rawCreatedAt: String) = ConnectedEvent( + type = "type", + createdAt = createdAt, + rawCreatedAt = rawCreatedAt, + connectionId = randomString(), + me = user, + ) + + // endregion + private fun TestScope.localRandomMessage() = randomMessage( createdLocallyAt = Date(currentTime), createdAt = null, diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 681fdab96b3..366ef3d2351 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -428,9 +428,10 @@ internal class QueryChannelsLogicTest { whenever(queryChannelsStateLogic.getChannels()) doReturn existingChannels // When - logic.prefillChannels(newChannels, request) + logic.prefillChannels(newChannels, request, "key") // Then + verify(queryChannelsStateLogic).setGroupKey("key") verify(queryChannelsStateLogic).setCurrentRequest(request) verify(queryChannelsStateLogic).removeChannels(existingChannels.keys) verify(queryChannelsStateLogic).addChannelsState(newChannels) @@ -448,7 +449,7 @@ internal class QueryChannelsLogicTest { whenever(queryChannelsStateLogic.getChannels()) doReturn null // When - logic.prefillChannels(emptyList(), request) + logic.prefillChannels(emptyList(), request, "key") // Then verify(queryChannelsStateLogic).setChannelsOffset(0) @@ -463,7 +464,7 @@ internal class QueryChannelsLogicTest { whenever(queryChannelsStateLogic.getChannels()) doReturn null // When - logic.prefillChannels(newChannels, request) + logic.prefillChannels(newChannels, request, "key") // Then verify(queryChannelsStateLogic, never()).removeChannels(any()) @@ -478,7 +479,7 @@ internal class QueryChannelsLogicTest { whenever(queryChannelsStateLogic.getChannels()) doReturn null // When - logic.prefillChannels(channels, request) + logic.prefillChannels(channels, request, "key") // Then verify(queryChannelsDatabaseLogic).insertQueryChannels(queryChannelsSpec) From 41468537e209acf20cb14d28348b629f1c49c876 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 24 Apr 2026 14:45:06 +0200 Subject: [PATCH 11/21] Reference channels in DB by their groupKey. --- .../api/stream-chat-android-client.api | 5 +- .../chat/android/client/api2/MoshiChatApi.kt | 1 + .../repository/QueryChannelsRepository.kt | 7 ++- .../noop/NoOpQueryChannelsRepository.kt | 8 +++- .../android/client/query/QueryChannelsSpec.kt | 1 + .../ChatClientGroupedChannelsApiTests.kt | 11 +++-- .../api/stream-chat-android-compose.api | 2 +- .../channels/ChannelListViewModel.kt | 13 +++-- .../api/stream-chat-android-core.api | 14 +++--- .../chat/android/models/GroupedChannels.kt | 1 + .../DatabaseQueryChannelsRepository.kt | 11 +++-- .../android/state/extensions/ChatClient.kt | 11 ++--- .../internal/QueryChannelsDatabaseLogic.kt | 2 +- .../internal/QueryChannelsLogic.kt | 47 ++++++++++--------- .../internal/QueryChannelsStateLogic.kt | 1 + .../state/internal/ChatClientStateCalls.kt | 7 +-- .../state/sync/internal/SyncManager.kt | 6 +-- .../android/state/internal/SyncManagerTest.kt | 4 +- .../QueryGroupedChannelsListenerStateTest.kt | 6 +-- .../QueryChannelsDatabaseLogicTest.kt | 12 ++--- .../internal/QueryChannelsLogicTest.kt | 13 +++-- .../internal/ChatClientStateCallsTest.kt | 7 +-- 22 files changed, 109 insertions(+), 81 deletions(-) diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index e5b26ea4104..467dd4950ec 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -3061,7 +3061,8 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract interface class io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository { public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun insertQueryChannels (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun selectBy$default (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/persistance/repository/ReactionRepository { @@ -3483,9 +3484,11 @@ public final class io/getstream/chat/android/client/query/QueryChannelsSpec { public fun equals (Ljava/lang/Object;)Z public final fun getCids ()Ljava/util/Set; public final fun getFilter ()Lio/getstream/chat/android/models/FilterObject; + public final fun getGroupKey ()Ljava/lang/String; public final fun getQuerySort ()Lio/getstream/chat/android/models/querysort/QuerySorter; public fun hashCode ()I public final fun setCids (Ljava/util/Set;)V + public final fun setGroupKey (Ljava/lang/String;)V public fun toString ()Ljava/lang/String; } 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 baa5a79b3ef..cdb75017efa 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 @@ -1328,6 +1328,7 @@ constructor( GroupedChannels( groups = response.groups.mapValues { entry -> GroupedChannelsGroup( + groupKey = entry.key, channels = entry.value.channels.map(::flattenChannel), unreadChannels = entry.value.unread_channels ?: 0, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt index 54818400314..680a7a6d868 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt @@ -38,8 +38,13 @@ public interface QueryChannelsRepository { * * @param filter [FilterObject] * @param querySort [QuerySorter] + * @param groupKey Optional stable key that overrides the filter+sort hash for lookup. */ - public suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? + public suspend fun selectBy( + filter: FilterObject, + querySort: QuerySorter, + groupKey: String? = null, + ): QueryChannelsSpec? /** * Clear QueryChannels of this repository. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt index efac3272afc..fa3f22fd3e5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt @@ -27,6 +27,12 @@ import io.getstream.chat.android.models.querysort.QuerySorter */ internal object NoOpQueryChannelsRepository : QueryChannelsRepository { override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) { /* No-Op */ } - override suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? = null + + override suspend fun selectBy( + filter: FilterObject, + querySort: QuerySorter, + groupKey: String?, + ): QueryChannelsSpec? = null + override suspend fun clear() { /* No-Op */ } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt index d9a60d78450..83596b56e34 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt @@ -24,5 +24,6 @@ public data class QueryChannelsSpec( val filter: FilterObject, val querySort: QuerySorter, ) { + var groupKey: String? = null var cids: Set = emptySet() } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt index 39270455386..2377e680ef3 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt @@ -43,10 +43,13 @@ internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { // given val groupedChannels = GroupedChannels( groups = mapOf( - randomString() to GroupedChannelsGroup( - channels = listOf(randomChannel()), - unreadChannels = randomInt(), - ), + randomString().let { key -> + key to GroupedChannelsGroup( + groupKey = key, + channels = listOf(randomChannel()), + unreadChannels = randomInt(), + ) + }, ), ) val sut = Fixture() diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 6f293bbfc4c..8a9e71b6e81 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4913,7 +4913,7 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final fun muteChannel (Lio/getstream/chat/android/models/Channel;)V public final fun performChannelAction (Lio/getstream/chat/android/ui/common/state/channels/actions/ChannelAction;)V public final fun pinChannel (Lio/getstream/chat/android/models/Channel;)V - public final fun prefill (Ljava/util/List;Ljava/lang/String;)V + public final fun prefill (Lio/getstream/chat/android/models/GroupedChannelsGroup;)V public final fun refresh ()V public final fun selectChannel (Lio/getstream/chat/android/models/Channel;)V public final fun setFilters (Lio/getstream/chat/android/models/FilterObject;)V diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 453ebdac1eb..e2723d5047e 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -37,6 +37,7 @@ import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.User @@ -551,13 +552,11 @@ public class ChannelListViewModel( * Requires [skipInitialQuery] to be `true`. Can be called at any time after ViewModel * creation — if the state is not yet initialized, the call suspends until it is ready. * - * @param channels The channels to populate the list with. - * @param groupKey Optional key from [io.getstream.chat.android.models.GroupedChannels.groups] - * that identifies which group this list belongs to. When set, the SDK will automatically - * call `queryGroupedChannels` instead of `queryChannels` during WebSocket reconnect. + * @param group The [GroupedChannelsGroup] containing the channels and group key. + * The group key identifies which group this list belongs to. */ - public fun prefill(channels: List, groupKey: String) { - logger.d { "[prefill] channels.size: ${channels.size}, groupKey: $groupKey" } + public fun prefill(group: GroupedChannelsGroup) { + logger.d { "[prefill] channels.size: ${group.channels.size}, groupKey: ${group.groupKey}" } if (!skipInitialQuery) { logger.w { "[prefill] rejected (skipInitialQuery is false)" } return @@ -572,7 +571,7 @@ public class ChannelListViewModel( messageLimit = messageLimit, memberLimit = memberLimit, ) - chatClient.prefillQueryChannels(request, channels, groupKey) + chatClient.prefillQueryChannels(request, group) } } diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index e11309c590b..5858d32f1e5 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1093,14 +1093,16 @@ public final class io/getstream/chat/android/models/GroupedChannels { } public final class io/getstream/chat/android/models/GroupedChannelsGroup { - public fun (Ljava/util/List;I)V - public synthetic fun (Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun component1 ()Ljava/util/List; - public final fun component2 ()I - public final fun copy (Ljava/util/List;I)Lio/getstream/chat/android/models/GroupedChannelsGroup; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/util/List;IILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public fun (Ljava/lang/String;Ljava/util/List;I)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()I + public final fun copy (Ljava/lang/String;Ljava/util/List;I)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/lang/String;Ljava/util/List;IILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; public fun equals (Ljava/lang/Object;)Z public final fun getChannels ()Ljava/util/List; + public final fun getGroupKey ()Ljava/lang/String; public final fun getUnreadChannels ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt index d31035f3f84..a5f258e484f 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt @@ -30,6 +30,7 @@ public data class GroupedChannels(public val groups: Map, public val unreadChannels: Int = 0, ) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt index 728bc67ed12..8d479ca12cb 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt @@ -44,8 +44,13 @@ internal class DatabaseQueryChannelsRepository( * @param filter [FilterObject] * @param querySort [QuerySorter] */ - override suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? { - return queryChannelsDao.select(generateId(filter, querySort))?.let(Companion::toModel) + override suspend fun selectBy( + filter: FilterObject, + querySort: QuerySorter, + groupKey: String?, + ): QueryChannelsSpec? { + val id = groupKey ?: generateId(filter, querySort) + return queryChannelsDao.select(id)?.let(Companion::toModel) } override suspend fun clear() { @@ -59,7 +64,7 @@ internal class DatabaseQueryChannelsRepository( private fun toEntity(queryChannelsSpec: QueryChannelsSpec): QueryChannelsEntity = QueryChannelsEntity( - generateId(queryChannelsSpec.filter, queryChannelsSpec.querySort), + queryChannelsSpec.groupKey ?: generateId(queryChannelsSpec.filter, queryChannelsSpec.querySort), queryChannelsSpec.filter, queryChannelsSpec.querySort, queryChannelsSpec.cids.toList(), diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index 702da3d536f..ebfb6e91d47 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler @@ -179,18 +180,16 @@ public fun ChatClient.initQueryChannelsAsState( * The state must have been previously created via [initQueryChannelsAsState] or [queryChannelsAsState]. * * @param request The [QueryChannelsRequest] identifying the query to populate. - * @param channels The channels to inject into the state. - * @param groupKey The key identifying the group. + * @param group The [GroupedChannelsGroup] containing the channels and group key. */ @InternalStreamChatApi public suspend fun ChatClient.prefillQueryChannels( request: QueryChannelsRequest, - channels: List, - groupKey: String, + group: GroupedChannelsGroup, ) { - StreamLog.d(TAG) { "[prefillQueryChannels] channels.size: ${channels.size}, groupKey: $groupKey" } + StreamLog.d(TAG) { "[prefillQueryChannels] channels.size: ${group.channels.size}, groupKey: ${group.groupKey}" } clientState.user.first { it != null } - logic.queryChannels(request).prefillChannels(channels, request, groupKey) + logic.queryChannels(request).prefillChannels(group, request) } /** diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt index d35b7e083d0..46dce1814ab 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt @@ -51,7 +51,7 @@ internal class QueryChannelsDatabaseLogic( queryChannelsSpec: QueryChannelsSpec?, ): List? { val cachedSpec = queryChannelsSpec?.let { - queryChannelsRepository.selectBy(it.filter, it.querySort) + queryChannelsRepository.selectBy(it.filter, it.querySort, it.groupKey) } return if (cachedSpec != null) { // Spec is present in DB, fetch channels according to it diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index bd939825088..b36de5c6262 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.client.query.request.ChannelFilterRequest.filte import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelConfig import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult @@ -176,51 +177,55 @@ internal class QueryChannelsLogic( } /** - * Replaces the current query's channels with the provided [channels] and persists + * Replaces the current query's channels with the provided [group]'s channels and persists * the result to the local database. No remote API call is made. + * + * Before writing fresh data, an optimistic offline load is performed: cached channels from + * a prior session are read from the DB using the stable [GroupedChannelsGroup.groupKey] and + * shown immediately. This mirrors iOS where the CoreData observer re-evaluates its predicate + * as soon as `query.groupKey` is set. */ internal suspend fun prefillChannels( - channels: List, + group: GroupedChannelsGroup, request: QueryChannelsRequest, - groupKey: String, ) { + val groupKey = group.groupKey + val channels = group.channels logger.d { "[prefillChannels] channels.size: ${channels.size}, groupKey: $groupKey" } - // Store the group key so SyncManager can route this query through - // queryGroupedChannels instead of individual queryChannels on reconnect. + // 1. Set groupKey so all DB operations use the stable key. + // Also signals SyncManager to route reconnect through queryGroupedChannels. queryChannelsStateLogic.setGroupKey(groupKey) - // Set current request (needed for nextPageRequest derivation used by loadMore) + // 2. Set current request (needed for nextPageRequest derivation used by loadMore) queryChannelsStateLogic.setCurrentRequest(request) - // Remove any existing channels from the state map so addChannelsState - // doesn't merge stale data with the new prefilled channels. + // 3. Optimistic offline load: read cached channels from DB using the stable groupKey. + val cachedChannels = fetchChannelsFromCache( + request.toOfflinePaginationRequest(), + queryChannelsDatabaseLogic, + ) + if (!cachedChannels.isNullOrEmpty()) { + logger.d { "[prefillChannels] showing ${cachedChannels.size} cached channels" } + queryChannelsStateLogic.addChannelsState(cachedChannels) + queryChannelsStateLogic.setLoadingFirstPage(false) + } + + // 4. Replace with fresh channels from the API val existingChannels = queryChannelsStateLogic.getChannels() if (!existingChannels.isNullOrEmpty()) { queryChannelsStateLogic.removeChannels(existingChannels.keys) } - - // Clear query spec CIDs (replace semantics) queryChannelsStateLogic.getQuerySpecs().cids = emptySet() - - // Add channels to in-memory state (also updates per-channel ChannelState via LogicRegistry) queryChannelsStateLogic.addChannelsState(channels) - - // Set pagination offset = prefilled count queryChannelsStateLogic.setChannelsOffset(channels.size) - - // endOfChannels only if zero channels (allow pagination otherwise) queryChannelsStateLogic.setEndOfChannels(channels.isEmpty()) - - // Mark loading complete queryChannelsStateLogic.setLoadingFirstPage(false) queryChannelsStateLogic.setLoadingMore(false) queryChannelsStateLogic.setRecoveryNeeded(false) - // Persist query spec (cids) to DB + // 5. Persist fresh data to DB under the stable groupKey queryChannelsDatabaseLogic.insertQueryChannels(queryChannelsStateLogic.getQuerySpecs()) - - // Persist channel data (configs + channels + messages) to DB val channelConfigs = channels.map { ChannelConfig(it.type, it.config) } queryChannelsDatabaseLogic.insertChannelConfigs(channelConfigs) queryChannelsDatabaseLogic.storeStateForChannels(channels.toSet()) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index d8d3da25e96..cb391a8aaec 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -128,6 +128,7 @@ internal class QueryChannelsStateLogic( internal fun setGroupKey(key: String?) { mutableState.groupKey = key + mutableState.queryChannelsSpec.groupKey = key } /** diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt index 1181a47d90e..31dbfd8a52c 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt @@ -24,7 +24,6 @@ import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory -import io.getstream.chat.android.state.extensions.internal.logic import io.getstream.chat.android.state.extensions.state import io.getstream.chat.android.state.model.querychannels.pagination.internal.QueryChannelPaginationRequest import io.getstream.chat.android.state.plugin.state.StateRegistry @@ -83,14 +82,10 @@ internal class ChatClientStateCalls( ): QueryChannelsState { logger.d { "[initQueryChannelsState] request: $request" } chatClient.clientState.user.first { it != null } - val state = deferredState + return deferredState .await() .queryChannels(request.filter, request.querySort) .apply { this.chatEventHandlerFactory = chatEventHandlerFactory } - chatClient.logic.queryChannels(request).apply { - loadOfflineChannels(request) - } - return state } /** Reference request of the channel query. */ diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index 723cb5e59af..fdeedcff55c 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -463,12 +463,12 @@ internal class SyncManager( val grouped = result.value groupKeyToLogic.forEach { (key, logic) -> - val channels = grouped.groups[key]?.channels ?: emptyList() - newCids.addAll(channels.map { it.cid }) + val group = grouped.groups[key] ?: return@forEach + newCids.addAll(group.channels.map { it.cid }) val currentRequest = logic.currentRequest() if (currentRequest != null) { - logic.prefillChannels(channels, currentRequest, key) + logic.prefillChannels(group, currentRequest) } else { logger.w { "[updateGroupedQueryChannels] no current request for group '$key', skipping prefill" diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt index 0ed06318878..1d0ec7fee8e 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt @@ -543,7 +543,7 @@ internal class SyncManagerTest { whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( Result.Success( GroupedChannels( - groups = mapOf("all" to GroupedChannelsGroup(channels = listOf(channelA, channelB))), + groups = mapOf("all" to GroupedChannelsGroup(groupKey = "all", channels = listOf(channelA, channelB))), ), ), ) @@ -586,7 +586,7 @@ internal class SyncManagerTest { whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( Result.Success( GroupedChannels( - groups = mapOf("all" to GroupedChannelsGroup(channels = listOf(channelA))), + groups = mapOf("all" to GroupedChannelsGroup(groupKey = "all", channels = listOf(channelA))), ), ), ) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt index 6ab7f41b5d3..d9deb6af2f8 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt @@ -42,8 +42,8 @@ internal class QueryGroupedChannelsListenerStateTest { val result = Result.Success( value = GroupedChannels( groups = mapOf( - "direct" to GroupedChannelsGroup(channels = emptyList(), unreadChannels = 3), - "support" to GroupedChannelsGroup(channels = emptyList(), unreadChannels = 1), + "direct" to GroupedChannelsGroup(groupKey = "direct", channels = emptyList(), unreadChannels = 3), + "support" to GroupedChannelsGroup(groupKey = "support", channels = emptyList(), unreadChannels = 1), ), ), ) @@ -60,7 +60,7 @@ internal class QueryGroupedChannelsListenerStateTest { val result = Result.Success( value = GroupedChannels( groups = mapOf( - "expired" to GroupedChannelsGroup(channels = emptyList(), unreadChannels = 0), + "expired" to GroupedChannelsGroup(groupKey = "expired", channels = emptyList(), unreadChannels = 0), ), ), ) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt index 1305bd698d9..0b0545d788f 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt @@ -89,14 +89,14 @@ internal class QueryChannelsDatabaseLogicTest { val pagination = AnyChannelPaginationRequest() val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) - whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn null + whenever(queryChannelsRepository.selectBy(filter, sort, null)) doReturn null // When val result = logic.fetchChannelsFromCache(pagination, queryChannelsSpec) // Then assertNull(result) - verify(queryChannelsRepository).selectBy(filter, sort) + verify(queryChannelsRepository).selectBy(filter, sort, null) } @Test @@ -125,7 +125,7 @@ internal class QueryChannelsDatabaseLogicTest { val channel3 = randomChannel(id = "channel3", type = "messaging") val expectedChannels = listOf(channel1, channel2, channel3) - whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn cachedSpec + whenever(queryChannelsRepository.selectBy(filter, sort, null)) doReturn cachedSpec whenever(repositoryFacade.selectChannels(listOf(cid1, cid2, cid3), pagination)) doReturn expectedChannels // When @@ -133,7 +133,7 @@ internal class QueryChannelsDatabaseLogicTest { // Then assertEquals(expectedChannels, result) - verify(queryChannelsRepository).selectBy(filter, sort) + verify(queryChannelsRepository).selectBy(filter, sort, null) verify(repositoryFacade).selectChannels(listOf(cid1, cid2, cid3), pagination) } @@ -151,7 +151,7 @@ internal class QueryChannelsDatabaseLogicTest { ) val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) - whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn cachedSpec + whenever(queryChannelsRepository.selectBy(filter, sort, null)) doReturn cachedSpec whenever(repositoryFacade.selectChannels(emptyList(), pagination)) doReturn emptyList() // When @@ -159,7 +159,7 @@ internal class QueryChannelsDatabaseLogicTest { // Then assertEquals(emptyList(), result) - verify(queryChannelsRepository).selectBy(filter, sort) + verify(queryChannelsRepository).selectBy(filter, sort, null) verify(repositoryFacade).selectChannels(emptyList(), pagination) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 366ef3d2351..e2491c2439b 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult @@ -425,10 +426,11 @@ internal class QueryChannelsLogicTest { val oldChannel = randomChannel(id = "old") val existingChannels = mapOf(oldChannel.cid to oldChannel) val newChannels = listOf(randomChannel(id = "new1"), randomChannel(id = "new2"), randomChannel(id = "new3")) + val group = GroupedChannelsGroup(groupKey = "key", channels = newChannels) whenever(queryChannelsStateLogic.getChannels()) doReturn existingChannels // When - logic.prefillChannels(newChannels, request, "key") + logic.prefillChannels(group, request) // Then verify(queryChannelsStateLogic).setGroupKey("key") @@ -446,10 +448,11 @@ internal class QueryChannelsLogicTest { fun `prefillChannels with empty list marks end of channels`() = runTest { // Given val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + val group = GroupedChannelsGroup(groupKey = "key", channels = emptyList()) whenever(queryChannelsStateLogic.getChannels()) doReturn null // When - logic.prefillChannels(emptyList(), request, "key") + logic.prefillChannels(group, request) // Then verify(queryChannelsStateLogic).setChannelsOffset(0) @@ -461,10 +464,11 @@ internal class QueryChannelsLogicTest { // Given val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) val newChannels = listOf(randomChannel()) + val group = GroupedChannelsGroup(groupKey = "key", channels = newChannels) whenever(queryChannelsStateLogic.getChannels()) doReturn null // When - logic.prefillChannels(newChannels, request, "key") + logic.prefillChannels(group, request) // Then verify(queryChannelsStateLogic, never()).removeChannels(any()) @@ -476,10 +480,11 @@ internal class QueryChannelsLogicTest { // Given val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) val channels = listOf(randomChannel(), randomChannel()) + val group = GroupedChannelsGroup(groupKey = "key", channels = channels) whenever(queryChannelsStateLogic.getChannels()) doReturn null // When - logic.prefillChannels(channels, request, "key") + logic.prefillChannels(group, request) // Then verify(queryChannelsDatabaseLogic).insertQueryChannels(queryChannelsSpec) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt index d3a389aef0a..2609e8e2ff3 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt @@ -96,7 +96,7 @@ internal class ChatClientStateCallsTest { } @Test - fun `initQueryChannelsState creates state without API call and configures it`() = runTest { + fun `initQueryChannelsState creates state without API call`() = runTest { // Given - user is connected userFlow.value = User(id = "test-user") val factory = ChatEventHandlerFactory(clientState) @@ -104,8 +104,7 @@ internal class ChatClientStateCallsTest { // When val result = chatClientStateCalls.initQueryChannelsState(request, factory) - // Then - verify(queryChannelsLogic).loadOfflineChannels(request) + // Then — no API call, no offline load (offline read happens later in prefillChannels) verify(chatClient, never()).queryChannels(any()) assertNotNull(result) } @@ -125,7 +124,6 @@ internal class ChatClientStateCallsTest { // Then - should not have completed yet assertEquals(false, completed) - verify(queryChannelsLogic, never()).loadOfflineChannels(any()) // When - user connects userFlow.value = User(id = "test-user") @@ -133,7 +131,6 @@ internal class ChatClientStateCallsTest { // Then - should complete now assertEquals(true, completed) - verify(queryChannelsLogic).loadOfflineChannels(request) job.cancel() } From 3c6657f695f71b775dff7525ac6f409ddc06ccc7 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 27 Apr 2026 17:42:34 +0200 Subject: [PATCH 12/21] Add re-watch mechanism for manually watched channels. --- .../android/state/extensions/ChatClient.kt | 18 ++++- .../state/plugin/state/StateRegistry.kt | 33 ++++++++ .../state/internal/WatchedChannelRecord.kt | 26 ++++++ .../state/internal/WatchedChannelStateFlow.kt | 32 ++++++++ .../state/sync/internal/SyncManager.kt | 80 +++++++++++-------- .../android/state/internal/SyncManagerTest.kt | 18 +---- .../state/plugin/state/StateRegistryTest.kt | 40 ++++++++++ 7 files changed, 197 insertions(+), 50 deletions(-) create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelRecord.kt create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index ebfb6e91d47..02754ede3c3 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -49,6 +49,8 @@ import io.getstream.chat.android.state.plugin.internal.StatePlugin import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.global.GlobalState +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelStateFlow +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState import io.getstream.log.StreamLog @@ -71,6 +73,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -210,9 +213,22 @@ public fun ChatClient.watchChannelAsState( coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), ): StateFlow { StreamLog.i(TAG) { "[watchChannelAsState] cid: $cid, messageLimit: $messageLimit" } - return getStateOrNull(coroutineScope) { + val record = WatchedChannelRecord(cid) + // Register the record in a short-lived coroutine that waits for initialization. + // IMPORTANT: do NOT capture record in the getStateOrNull lambda — that lambda + // is kept alive by the coroutineScope (ChatClient-scoped) and would prevent + // the record from being GC'd when the caller drops the returned flow. + // This launch completes after registration, releasing its lambda captures. + coroutineScope.launch { + runCatching { + clientState.initializationState.first { it == InitializationState.COMPLETE } + state.trackWatchedChannel(record) + } + } + val flow = getStateOrNull(coroutineScope) { requestsAsState(coroutineScope).watchChannel(cid, messageLimit, stateConfig.userPresence) } + return WatchedChannelStateFlow(flow, record) } /** diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt index 5f55d9e7797..7c13da64e8f 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt @@ -31,6 +31,7 @@ import io.getstream.chat.android.state.plugin.config.MessageLimitConfig import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.channel.thread.internal.ThreadMutableState +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState @@ -40,6 +41,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.StateFlow +import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap /** @@ -72,6 +74,8 @@ public class StateRegistry( ConcurrentHashMap() private val threads: ConcurrentHashMap = ConcurrentHashMap() + private val watchedChannelRecords = mutableListOf>() + /** * Returns [QueryChannelsState] associated with particular [filter] and [sort]. * @@ -183,6 +187,32 @@ public class StateRegistry( internal fun getActiveChannelStates(): List = channels.values.toList() + /** + * Tracks a channel that was watched via [io.getstream.chat.android.state.extensions.watchChannelAsState]. + * The record lives as long as the caller holds the returned [StateFlow]. + * When the caller is GC'd, the tracker is GC'd and the weak reference goes null. + * Used during reconnect to re-watch only channels the user still has open. + * + * @param record The [WatchedChannelRecord] identifying the watched channel. + */ + internal fun trackWatchedChannel(record: WatchedChannelRecord) { + synchronized(watchedChannelRecords) { + watchedChannelRecords.removeAll { it.get() == null } + watchedChannelRecords.add(WeakReference(record)) + } + } + + /** + * Retrieves that channel CIDs which were registered via [trackWatchedChannel] and are still strongly referenced. + * Use to retrieve watched channels whose [StateFlow] is referenced by a consumer. + */ + internal fun getTrackedWatchedChannels(): Set { + synchronized(watchedChannelRecords) { + watchedChannelRecords.removeAll { it.get() == null } + return watchedChannelRecords.mapNotNull { it.get()?.cid }.toSet() + } + } + /** * Clear state of all state objects. */ @@ -196,6 +226,9 @@ public class StateRegistry( queryThreads.clear() threads.forEach { it.value.destroy() } threads.clear() + synchronized(watchedChannelRecords) { + watchedChannelRecords.clear() + } } internal fun handleBatchEvent(batchEvent: BatchEvent) { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelRecord.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelRecord.kt new file mode 100644 index 00000000000..750612b565c --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelRecord.kt @@ -0,0 +1,26 @@ +/* + * 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.state.plugin.state.internal + +/** + * Marker object that identifies an active channel watch. Held as a field inside + * [WatchedChannelStateFlow] so it lives as long as the caller holds the flow. + * [io.getstream.chat.android.state.plugin.state.StateRegistry] stores + * [java.lang.ref.WeakReference]s to these — when the flow is GC'd, the tracker + * is GC'd and the weak reference goes null. + */ +internal data class WatchedChannelRecord(val cid: String) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt new file mode 100644 index 00000000000..be55136b3c2 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt @@ -0,0 +1,32 @@ +/* + * 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.state.plugin.state.internal + +import io.getstream.chat.android.client.channel.state.ChannelState +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.flow.StateFlow + +/** + * A [StateFlow] wrapper that keeps a [WatchedChannelRecord] alive via a strong reference. + * Returned by [io.getstream.chat.android.state.extensions.watchChannelAsState] so the + * record's lifetime matches the caller's lifetime. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class WatchedChannelStateFlow( + private val delegate: StateFlow, + @Suppress("unused") private val record: WatchedChannelRecord, +) : StateFlow by delegate diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index fdeedcff55c..ebcecea25be 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -407,64 +407,62 @@ internal class SyncManager( val recoverAll = !isFirstConnect.compareAndSet(true, false) logger.d { "[restoreActiveChannels] recoverAll: $recoverAll" } - // 1. Refresh grouped query channels (prefilled ones) via a single queryGroupedChannels call. - val groupedCids = updateGroupedQueryChannels(recoverAll) - - // 2. Refresh standard (non-grouped) query channels via individual queryFirstPage calls. - val standardCids = when (val result = updateActiveQueryChannels(recoverAll)) { - is Result.Success -> { - logger.v { "[restoreActiveChannels] standardCids.size: ${result.value.size}" } - result.value - } - is Result.Failure -> { - logger.e { "[restoreActiveChannels] standard query failed: ${result.value}" } - emptySet() + val allLogics = logicRegistry.getActiveQueryChannelsLogic() + val hasGroupedQueries = allLogics.any { it.groupKey() != null } + val hasStandardQueries = allLogics.any { it.groupKey() == null } + + // --- GroupedQueryChannels path --- + if (hasGroupedQueries) { + // Refresh first page of the queries populated via GroupedQueryChannels (doesn't re-watch) + updateGroupedQueryChannels(recoverAll) + // Re-watch tracked channels (specific for this path, where we don't re-watch the groups, just the manually + // opened/tracked channels) + rewatchTrackedWatchedChannels() + } + + // --- QueryChannels path --- + if (hasStandardQueries) { + when (val result = updateActiveQueryChannels(recoverAll)) { + is Result.Success -> { + val updatedCids = result.value + logger.v { "[restoreActiveChannels] standardCids.size: ${result.value.size}" } + updateActiveChannels(recoverAll, updatedCids) + } + is Result.Failure -> { + logger.e { "[restoreActiveChannels] standard query failed: ${result.value}" } + return + } } } - - // 3. Re-watch individual channels not covered by steps 1 or 2. - updateActiveChannels(recoverAll, groupedCids + standardCids) } /** * For [QueryChannelsLogic] instances populated via grouped channels ([prefill][QueryChannelsLogic.prefillChannels]), * calls [ChatClient.queryGroupedChannels] once and re-prefills each with fresh data. - * - * @return The union of all CIDs from both the old state and the new grouped response, - * so they can be excluded from individual channel re-watches. */ - private suspend fun updateGroupedQueryChannels(recoverAll: Boolean): Set { + private suspend fun updateGroupedQueryChannels(recoverAll: Boolean) { val groupedLogics = logicRegistry.getActiveQueryChannelsLogic() .filter { it.groupKey() != null } .filter { it.recoveryNeeded().value || recoverAll } if (groupedLogics.isEmpty()) { logger.v { "[updateGroupedQueryChannels] no grouped queries to restore" } - return emptySet() + return } logger.d { "[updateGroupedQueryChannels] groupedLogics.size: ${groupedLogics.size}" } - // Collect ALL active ChannelLogic CIDs (not just queryChannelsSpec.cids) because - // ChannelState entries persist in the StateRegistry even after prefillChannels resets - // the query's tracking set on previous reconnects. - val oldCids = logicRegistry.getActiveChannelsLogic().map { it.cid }.toMutableSet() val groupKeyToLogic = mutableMapOf() groupedLogics.forEach { logic -> val key = logic.groupKey() ?: return@forEach groupKeyToLogic[key] = logic } - logger.d { "[updateGroupedQueryChannels] oldCids.size: ${oldCids.size}" } - val result = chatClient.queryGroupedChannels().await() - - return when (result) { + when (val result = chatClient.queryGroupedChannels().await()) { is Result.Success -> { - val newCids = mutableSetOf() val grouped = result.value groupKeyToLogic.forEach { (key, logic) -> val group = grouped.groups[key] ?: return@forEach - newCids.addAll(group.channels.map { it.cid }) val currentRequest = logic.currentRequest() if (currentRequest != null) { @@ -475,18 +473,30 @@ internal class SyncManager( } } } - logger.v { - "[updateGroupedQueryChannels] succeeded; oldCids=${oldCids.size}, newCids=${newCids.size}" - } - oldCids + newCids + logger.v { "[updateGroupedQueryChannels] succeeded" } } is Result.Failure -> { logger.e { "[updateGroupedQueryChannels] queryGroupedChannels failed: ${result.value}" } - oldCids } } } + /** + * Re-watches channels explicitly opened by the user (tracked via + * [io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord] weak references in [StateRegistry]). + */ + private suspend fun rewatchTrackedWatchedChannels() { + val online = clientState.isOnline + val watchedCids = stateRegistry.getTrackedWatchedChannels() + logger.d { "[rewatchTrackedWatchedChannels] watchedCids.size: ${watchedCids.size}, online: $online" } + if (watchedCids.isEmpty() || !online) return + + watchedCids.forEach { cid -> + val (type, id) = cid.cidToTypeAndId() + logicRegistry.channel(type, id).watch(userPresence = userPresence) + } + } + private suspend fun updateActiveQueryChannels(recoverAll: Boolean): Result> { // 2. update the results for queries that are actively being shown right now (synchronous) logger.d { "[updateActiveQueryChannels] recoverAll: $recoverAll" } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt index 1d0ec7fee8e..76cb95dcae7 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt @@ -564,16 +564,13 @@ internal class SyncManagerTest { } @Test - fun `on reconnect grouped CIDs should be excluded from updateActiveChannels`() = + fun `on reconnect with only grouped queries updateActiveChannels should not run`() = runTest(testDispatcher) { val createdAt = localDate() val rawCreatedAt = streamDateFormatter.format(createdAt) val channelA = randomChannel(type = "messaging", id = "a") - val channelLogicA: ChannelLogic = mock { on(it.cid) doReturn "messaging:a" } - val channelLogicB: ChannelLogic = mock { on(it.cid) doReturn "messaging:b" } - val queryLogic: QueryChannelsLogic = mock { on(it.groupKey()) doReturn "all" on(it.currentRequest()) doReturn mock() @@ -581,8 +578,6 @@ internal class SyncManagerTest { } whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) - whenever(logicRegistry.getActiveChannelsLogic()) doReturn listOf(channelLogicA, channelLogicB) - whenever(stateRegistry.getActiveChannelStates()) doReturn emptyList() whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( Result.Success( GroupedChannels( @@ -600,19 +595,16 @@ internal class SyncManagerTest { syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) delay(100) - // queryChannelsInternal should NOT be called because all active CIDs - // (messaging:a, messaging:b) are excluded via the grouped oldCids set. + // The standard path (updateActiveChannels) should not run when only grouped queries exist. verify(chatClient, never()).queryChannelsInternal(any()) } @Test - fun `on reconnect with grouped query failure should still exclude old CIDs`() = + fun `on reconnect with grouped query failure standard path should not run`() = runTest(testDispatcher) { val createdAt = localDate() val rawCreatedAt = streamDateFormatter.format(createdAt) - val channelLogicA: ChannelLogic = mock { on(it.cid) doReturn "messaging:a" } - val queryLogic: QueryChannelsLogic = mock { on(it.groupKey()) doReturn "all" on(it.currentRequest()) doReturn mock() @@ -620,8 +612,6 @@ internal class SyncManagerTest { } whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) - whenever(logicRegistry.getActiveChannelsLogic()) doReturn listOf(channelLogicA) - whenever(stateRegistry.getActiveChannelStates()) doReturn emptyList() whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( Result.Failure( Error.NetworkError(message = "fail", serverErrorCode = 0, statusCode = 500), @@ -637,7 +627,7 @@ internal class SyncManagerTest { syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) delay(100) - // Even on failure, old CIDs should prevent updateActiveChannels from re-watching + // The standard path should not run when only grouped queries exist, even on failure. verify(chatClient, never()).queryChannelsInternal(any()) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt index 943a97b17a5..f7a6a124ea0 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt @@ -24,13 +24,16 @@ import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.descByName import io.getstream.chat.android.state.plugin.config.MessageLimitConfig +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord import io.getstream.chat.android.test.TestCoroutineExtension import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.TestScope +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotSame import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -249,4 +252,41 @@ internal class StateRegistryTest { // Then assertSame(state1, state2) } + + // region WatchedChannelRecord tracking + + @Test + fun `trackWatchedChannel should make CID available in getTrackedWatchedChannels`() { + val record = WatchedChannelRecord("messaging:123") + stateRegistry.trackWatchedChannel(record) + + assertEquals(setOf("messaging:123"), stateRegistry.getTrackedWatchedChannels()) + } + + @Test + fun `getTrackedWatchedChannels should return empty set when nothing tracked`() { + assertTrue(stateRegistry.getTrackedWatchedChannels().isEmpty()) + } + + @Test + fun `trackWatchedChannel should deduplicate CIDs from multiple records`() { + val record1 = WatchedChannelRecord("messaging:123") + val record2 = WatchedChannelRecord("messaging:123") + stateRegistry.trackWatchedChannel(record1) + stateRegistry.trackWatchedChannel(record2) + + assertEquals(setOf("messaging:123"), stateRegistry.getTrackedWatchedChannels()) + } + + @Test + fun `clear should remove all tracked records`() { + val record = WatchedChannelRecord("messaging:123") + stateRegistry.trackWatchedChannel(record) + + stateRegistry.clear() + + assertTrue(stateRegistry.getTrackedWatchedChannels().isEmpty()) + } + + // endregion } From c8c6eab72b99a046524d7be7a1aa4e5d18e994ec Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 27 Apr 2026 17:52:13 +0200 Subject: [PATCH 13/21] Fix KDoc. --- .../getstream/chat/android/state/plugin/state/StateRegistry.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt index 7c13da64e8f..26ba7cacacf 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt @@ -190,7 +190,7 @@ public class StateRegistry( /** * Tracks a channel that was watched via [io.getstream.chat.android.state.extensions.watchChannelAsState]. * The record lives as long as the caller holds the returned [StateFlow]. - * When the caller is GC'd, the tracker is GC'd and the weak reference goes null. + * When the caller is GC'd, the record is GC'd and the weak reference goes null. * Used during reconnect to re-watch only channels the user still has open. * * @param record The [WatchedChannelRecord] identifying the watched channel. From 2c3ef717f7ea1db8cae6f3a615ccdf27223c2ae5 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 27 Apr 2026 18:02:10 +0200 Subject: [PATCH 14/21] Fix Detekt. --- .../io/getstream/chat/android/state/extensions/ChatClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index 02754ede3c3..3de410857a2 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -49,8 +49,8 @@ import io.getstream.chat.android.state.plugin.internal.StatePlugin import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.global.GlobalState -import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelStateFlow import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelStateFlow import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState import io.getstream.log.StreamLog From c04032c747db0a7f9fd38c5ff9de6e33302f7483 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Wed, 13 May 2026 20:58:38 +0200 Subject: [PATCH 15/21] GroupedQueryChannels --- .../chat/android/client/test/Mother.kt | 2 +- .../api/stream-chat-android-client.api | 47 ++- .../chat/android/client/ChatClient.kt | 19 +- .../chat/android/client/api/ChatApi.kt | 21 +- .../chat/android/client/api2/MoshiChatApi.kt | 24 +- .../client/api2/endpoint/ChannelApi.kt | 7 +- .../requests/QueryGroupedChannelsRequest.kt | 21 +- .../response/QueryGroupedChannelsResponse.kt | 7 +- .../state/plugin/QueryChannelsIdentifier.kt | 65 ++++ .../repository/QueryChannelsRepository.kt | 18 +- .../noop/NoOpQueryChannelsRepository.kt | 10 +- .../chat/android/client/plugin/Plugin.kt | 2 + .../listeners/QueryGroupedChannelsListener.kt | 6 +- .../android/client/query/QueryChannelsSpec.kt | 34 +- .../ChatClientGroupedChannelsApiTests.kt | 46 ++- .../android/client/api2/MoshiChatApiTest.kt | 3 +- .../client/api2/MoshiChatApiTestArguments.kt | 2 + .../api/stream-chat-android-compose.api | 28 +- .../channels/ChannelListViewModel.kt | 361 ++++++++++++------ .../channels/ChannelViewModelFactory.kt | 121 ++++-- .../channels/ChannelListViewModelTest.kt | 144 ++----- .../api/stream-chat-android-core.api | 29 +- .../chat/android/models/GroupedChannels.kt | 21 +- .../database/internal/ChatDatabase.kt | 2 +- .../DatabaseQueryChannelsRepository.kt | 52 ++- .../internal/QueryChannelsEntity.kt | 1 + .../api/stream-chat-android-state.api | 1 + .../android/state/extensions/ChatClient.kt | 43 +-- .../state/plugin/internal/StatePlugin.kt | 2 +- .../QueryGroupedChannelsListenerState.kt | 26 +- .../plugin/logic/internal/LogicRegistry.kt | 18 +- .../internal/QueryChannelsDatabaseLogic.kt | 3 +- .../internal/QueryChannelsLogic.kt | 151 ++++---- .../internal/QueryChannelsStateLogic.kt | 15 +- .../state/plugin/state/StateRegistry.kt | 18 +- .../state/internal/ChatClientStateCalls.kt | 19 +- .../state/querychannels/QueryChannelsState.kt | 6 + .../internal/QueryChannelsMutableState.kt | 63 ++- .../state/sync/internal/SyncManager.kt | 43 +-- .../QueryGroupedChannelsListenerStateTest.kt | 159 +++++++- .../QueryChannelsDatabaseLogicTest.kt | 13 +- .../internal/QueryChannelsLogicTest.kt | 79 +--- .../internal/QueryChannelsStateLogicTest.kt | 17 +- .../internal/ChatClientStateCallsTest.kt | 26 +- .../internal/QueryChannelsMutableStateTest.kt | 5 +- 45 files changed, 1181 insertions(+), 619 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index 4a073cf6a26..a0df403c0df 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -694,7 +694,7 @@ public fun randomQueryChannelsSpec( filter: FilterObject = NeutralFilterObject, sort: QuerySorter = QuerySortByField(), cids: Set = emptySet(), -): QueryChannelsSpec = QueryChannelsSpec(filter, sort).apply { this.cids = cids } +): QueryChannelsSpec = QueryChannelsSpec(filter, sort, cids) public fun randomNotificationRemovedFromChannelEvent( cid: String = randomCID(), diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 467dd4950ec..2eb8f485315 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -164,8 +164,8 @@ public final class io/getstream/chat/android/client/ChatClient { public final fun queryDraftMessages (Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/result/call/Call; public final fun queryDrafts (Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/result/call/Call; public static synthetic fun queryDrafts$default (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/result/call/Call; - public final fun queryGroupedChannels (Ljava/lang/Integer;ZZ)Lio/getstream/result/call/Call; - public static synthetic fun queryGroupedChannels$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/Integer;ZZILjava/lang/Object;)Lio/getstream/result/call/Call; + public final fun queryGroupedChannels (Ljava/lang/Integer;Ljava/util/Map;ZZ)Lio/getstream/result/call/Call; + public static synthetic fun queryGroupedChannels$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/Integer;Ljava/util/Map;ZZILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryMembers (Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;)Lio/getstream/result/call/Call; public static synthetic fun queryMembers$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryPollVotes (Ljava/lang/String;Lio/getstream/chat/android/models/FilterObject;Ljava/lang/Integer;Ljava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/result/call/Call; @@ -2817,6 +2817,30 @@ public abstract interface class io/getstream/chat/android/client/interceptor/mes public abstract fun prepareMessage (Lio/getstream/chat/android/models/Message;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/models/Message; } +public final class io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Grouped : io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Grouped; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Grouped;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Grouped; + public fun equals (Ljava/lang/Object;)Z + public final fun getGroup ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Standard : io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier { + public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)V + public final fun component1 ()Lio/getstream/chat/android/models/FilterObject; + public final fun component2 ()Lio/getstream/chat/android/models/querysort/QuerySorter; + public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Standard; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Standard;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Standard; + public fun equals (Ljava/lang/Object;)Z + public final fun getFilter ()Lio/getstream/chat/android/models/FilterObject; + public final fun getSort ()Lio/getstream/chat/android/models/querysort/QuerySorter; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/client/logger/ChatLogLevel : java/lang/Enum { public static final field ALL Lio/getstream/chat/android/client/logger/ChatLogLevel; public static final field DEBUG Lio/getstream/chat/android/client/logger/ChatLogLevel; @@ -3061,8 +3085,9 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract interface class io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository { public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun insertQueryChannels (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun selectBy$default (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun selectBy (Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun selectBy$suspendImpl (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/persistance/repository/ReactionRepository { @@ -3222,8 +3247,8 @@ public abstract interface class io/getstream/chat/android/client/plugin/Plugin : public fun onQueryDraftMessagesResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryDraftMessagesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryDraftMessagesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun onQueryGroupedChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryGroupedChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryMembersResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryMembersResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryThreadsPrecondition (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3365,7 +3390,7 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener } public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener { - public abstract fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryMembersListener { @@ -3477,18 +3502,22 @@ public final class io/getstream/chat/android/client/query/CreateChannelParams { public final class io/getstream/chat/android/client/query/QueryChannelsSpec { public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)V + public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;)V + public synthetic fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/FilterObject; public final fun component2 ()Lio/getstream/chat/android/models/querysort/QuerySorter; + public final fun component3 ()Ljava/util/Set; + public final fun component4 ()Ljava/lang/String; public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; + public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; public static synthetic fun copy$default (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; public fun equals (Ljava/lang/Object;)Z public final fun getCids ()Ljava/util/Set; public final fun getFilter ()Lio/getstream/chat/android/models/FilterObject; public final fun getGroupKey ()Ljava/lang/String; public final fun getQuerySort ()Lio/getstream/chat/android/models/querysort/QuerySorter; public fun hashCode ()I - public final fun setCids (Ljava/util/Set;)V - public final fun setGroupKey (Ljava/lang/String;)V public fun toString ()Ljava/lang/String; } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index 1656a7bd9ad..6e5d12b5935 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -185,6 +185,7 @@ import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Flag import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Location @@ -3147,24 +3148,32 @@ internal constructor( } /** - * Queries channels grouped into server-defined buckets within a family. + * Queries channels grouped into server-defined groups. * - * @param limit The maximum number of channels to return per bucket. `null` uses the server default. + * Supports per-group request options (`limit`, `next`/`prev` cursors) and returns per-group + * pagination cursors. Pagination (`next` or `prev` on any group) is only allowed when + * exactly one group is requested. + * + * @param limit Default max channels per group when a group does not specify its own limit. + * `null` uses the server default. + * @param groups Optional per-group configuration keyed by group name. `null` returns the + * server-defined default set. * @param watch Whether to start watching the returned channels for real-time events. * @param presence Whether to receive presence events for the members of the returned channels. * - * @return A [Call] containing a [GroupedChannels] result with the family and its buckets. + * @return A [Call] containing a [GroupedChannels] with per-group channels and cursors. */ @CheckResult public fun queryGroupedChannels( limit: Int? = null, + groups: Map? = null, watch: Boolean = false, presence: Boolean = false, ): Call { - return api.queryGroupedChannels(limit = limit, watch = watch, presence = presence) + return api.queryGroupedChannels(limit = limit, groups = groups, watch = watch, presence = presence) .doOnResult(userScope) { result -> plugins.forEach { plugin -> - plugin.onQueryGroupedChannelsResult(result, limit, watch, presence) + plugin.onQueryGroupedChannelsResult(result, limit, groups, watch, presence) } } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt index 5cd5eee40b9..cdef75e87c8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt @@ -38,6 +38,7 @@ import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Flag import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member @@ -290,16 +291,28 @@ internal interface ChatApi { fun queryChannels(query: QueryChannelsRequest): Call> /** - * Queries channels grouped into server-defined buckets within a family. + * Queries channels grouped into server-defined groups. * - * @param limit The maximum number of channels to return per bucket. `null` uses the server default. + * Supports per-group request options (limit, next/prev cursors) and returns per-group + * pagination cursors. Pagination (`next` or `prev` on any group) is only allowed when + * exactly one group is requested. + * + * @param limit Default max channels per group when a group does not specify its own limit. + * `null` uses the server default. + * @param groups Optional per-group configuration keyed by group name. `null` returns the + * server-defined default set. * @param watch Whether to start watching the returned channels for real-time events. * @param presence Whether to receive presence events for the members of the returned channels. * - * @return A [Call] containing a [GroupedChannels] result with the family and its buckets. + * @return A [Call] containing a [GroupedChannels] with per-group channels and cursors. */ @CheckResult - fun queryGroupedChannels(limit: Int?, watch: Boolean, presence: Boolean): Call + fun queryGroupedChannels( + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ): Call @CheckResult fun updateUsers(users: List): Call> 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 cdb75017efa..c0aca77070d 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 @@ -73,6 +73,7 @@ import io.getstream.chat.android.client.api2.model.requests.PollVoteRequest import io.getstream.chat.android.client.api2.model.requests.QueryBannedUsersRequest import io.getstream.chat.android.client.api2.model.requests.QueryDraftMessagesRequest import io.getstream.chat.android.client.api2.model.requests.QueryDraftsRequest +import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsGroupRequest import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollVotesRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollsRequest @@ -129,6 +130,7 @@ import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Flag import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member @@ -1318,8 +1320,24 @@ constructor( } } - override fun queryGroupedChannels(limit: Int?, watch: Boolean, presence: Boolean): Call { - val body = QueryGroupedChannelsRequest(limit = limit, watch = watch, presence = presence) + override fun queryGroupedChannels( + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ): Call { + val body = QueryGroupedChannelsRequest( + limit = limit, + groups = groups?.mapValues { (_, query) -> + QueryGroupedChannelsGroupRequest( + limit = query.limit, + next = query.next, + prev = query.prev, + ) + }, + watch = watch, + presence = presence, + ) val lazyCall = { channelApi.queryGroupedChannels( connectionId = connectionId, @@ -1331,6 +1349,8 @@ constructor( groupKey = entry.key, channels = entry.value.channels.map(::flattenChannel), unreadChannels = entry.value.unread_channels ?: 0, + next = entry.value.next, + prev = entry.value.prev, ) }, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt index 3c8d1c8d0ad..6e7bca2f391 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt @@ -65,10 +65,13 @@ internal interface ChannelApi { ): RetrofitCall /** - * Queries channels grouped into server-defined buckets within a family. + * Queries channels grouped into server-defined groups. + * + * Supports per-group request options (limit, next/prev cursors) and returns per-group + * pagination cursors. Pagination is only allowed when exactly one group is requested. * * @param connectionId The current connection ID. - * @param body The request body containing limit, watch, and presence parameters. + * @param body The request body containing the optional per-group configuration map. */ @POST("/channels/grouped") fun queryGroupedChannels( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt index bc76fdd709a..1c3758c5600 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt @@ -21,13 +21,32 @@ import com.squareup.moshi.JsonClass /** * Request body for the grouped query channels endpoint (`POST /channels/grouped`). * - * @param limit The maximum number of channels to return per bucket. `null` uses the server default. + * @param limit Default max channels per group when a group does not specify its own limit. + * `null` uses the server default. + * @param groups Optional per-group configuration keyed by group name. Omitting `groups` returns + * the server-defined default set. Pagination (`next` or `prev` on any group) requires that + * exactly one group is requested. * @param watch Whether to start watching the returned channels for real-time events. * @param presence Whether to receive presence events for the members of the returned channels. */ @JsonClass(generateAdapter = true) internal data class QueryGroupedChannelsRequest( val limit: Int?, + val groups: Map?, val watch: Boolean, val presence: Boolean, ) + +/** + * Per-group request options inside a [QueryGroupedChannelsRequest]. + * + * @param limit Max channels for this group. `null` (or `0`) falls back to the request-level limit. + * @param next Cursor for the next page of this group. Mutually exclusive with [prev]. + * @param prev Cursor for the previous page of this group. Mutually exclusive with [next]. + */ +@JsonClass(generateAdapter = true) +internal data class QueryGroupedChannelsGroupRequest( + val limit: Int?, + val next: String?, + val prev: String?, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt index 3922aa7635e..ea4a7580b22 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt @@ -21,7 +21,8 @@ import com.squareup.moshi.JsonClass /** * Raw API response for the grouped query channels endpoint (`POST /channels/grouped`). * - * @param groups The list of channel groups. + * @param groups The channel groups keyed by group name. Each group carries its channels, + * unread count, and optional pagination cursors. * @param duration The server-reported request duration. */ @JsonClass(generateAdapter = true) @@ -35,9 +36,13 @@ internal data class QueryGroupedChannelsResponse( * * @param channels The channel responses that belong to this group. * @param unread_channels The number of channels with unread messages in this group. + * @param next Cursor for the next page of this group, or `null` if there is no further page. + * @param prev Cursor for the previous page of this group, or `null` if there is none. */ @JsonClass(generateAdapter = true) internal data class QueryGroupedChannelsGroup( val channels: List, val unread_channels: Int?, + val next: String?, + val prev: String?, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt new file mode 100644 index 00000000000..0970ebd081e --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt @@ -0,0 +1,65 @@ +/* + * 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.internal.state.plugin + +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.query.QueryChannelsSpec +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.querysort.QuerySorter + +/** + * Canonical key for query-channels state held in the `LogicRegistry`, `StateRegistry`, and + * the offline `QueryChannelsRepository`. Each variant represents a distinct way to identify + * a logical channel-list query. + */ +@InternalStreamChatApi +public sealed interface QueryChannelsIdentifier { + + /** + * Standard offset-based queryChannels. Identity is `(filter, sort)`. + */ + public data class Standard( + public val filter: FilterObject, + public val sort: QuerySorter, + ) : QueryChannelsIdentifier + + /** + * Grouped queryChannels. Identity is the stable [group] key returned by the server. + */ + public data class Grouped( + public val group: String, + ) : QueryChannelsIdentifier +} + +/** + * Resolves the identifier for a standard [QueryChannelsRequest]. Grouped identifiers are + * not derivable from a request — they are constructed directly by callers. + */ +@InternalStreamChatApi +public val QueryChannelsRequest.identifier: QueryChannelsIdentifier + get() = QueryChannelsIdentifier.Standard(filter, querySort) + +/** + * Resolves the identifier for a stored [QueryChannelsSpec]. A non-null [QueryChannelsSpec.groupKey] + * means this spec was originally produced by a grouped query. + */ +@InternalStreamChatApi +public val QueryChannelsSpec.identifier: QueryChannelsIdentifier + get() = groupKey?.let { QueryChannelsIdentifier.Grouped(it) } + ?: QueryChannelsIdentifier.Standard(filter, querySort) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt index 680a7a6d868..588868ba06f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.persistance.repository +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject @@ -33,18 +34,23 @@ public interface QueryChannelsRepository { */ public suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) + /** + * Selects the spec stored under [identifier]. + */ + public suspend fun selectBy(identifier: QueryChannelsIdentifier): QueryChannelsSpec? + /** * Selects by a filter and query sort. * * @param filter [FilterObject] * @param querySort [QuerySorter] - * @param groupKey Optional stable key that overrides the filter+sort hash for lookup. */ - public suspend fun selectBy( - filter: FilterObject, - querySort: QuerySorter, - groupKey: String? = null, - ): QueryChannelsSpec? + @Deprecated( + message = "Use selectBy(identifier) instead.", + replaceWith = ReplaceWith("selectBy(QueryChannelsIdentifier.Standard(filter, querySort))"), + ) + public suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? = + selectBy(QueryChannelsIdentifier.Standard(filter, querySort)) /** * Clear QueryChannels of this repository. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt index fa3f22fd3e5..c77e98dabc2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt @@ -16,11 +16,9 @@ package io.getstream.chat.android.client.persistance.repository.noop +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository import io.getstream.chat.android.client.query.QueryChannelsSpec -import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.FilterObject -import io.getstream.chat.android.models.querysort.QuerySorter /** * No-Op QueryChannelsRepository. @@ -28,11 +26,7 @@ import io.getstream.chat.android.models.querysort.QuerySorter internal object NoOpQueryChannelsRepository : QueryChannelsRepository { override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) { /* No-Op */ } - override suspend fun selectBy( - filter: FilterObject, - querySort: QuerySorter, - groupKey: String?, - ): QueryChannelsSpec? = null + override suspend fun selectBy(identifier: QueryChannelsIdentifier): QueryChannelsSpec? = null override suspend fun clear() { /* No-Op */ } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt index ae58fb94641..a14030a827f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt @@ -56,6 +56,7 @@ import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message @@ -429,6 +430,7 @@ public interface Plugin : override suspend fun onQueryGroupedChannelsResult( result: Result, limit: Int?, + groups: Map?, watch: Boolean, presence: Boolean, ) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt index 1f96a72195d..2f55f21494e 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.plugin.listeners import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.result.Result /** @@ -28,13 +29,16 @@ public interface QueryGroupedChannelsListener { * Called when the query grouped channels request completes. * * @param result The result of the query grouped channels request. - * @param limit The maximum number of channels per group that was requested. + * @param limit The request-level default per-group limit, or `null` for the server default. + * @param groups The per-group request options that were sent, or `null` when the request + * asked for the server-defined default set of groups. * @param watch Whether watching was requested. * @param presence Whether presence was requested. */ public suspend fun onQueryGroupedChannelsResult( result: Result, limit: Int?, + groups: Map?, watch: Boolean, presence: Boolean, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt index 83596b56e34..b38f8330023 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt @@ -20,10 +20,40 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.querysort.QuerySorter +/** + * Immutable identity of a channels query. + * + * @property filter Filter conditions for the query. + * @property querySort Sort specification for the query. + * @property cids CIDs of channels currently associated with this query. + * @property groupKey Non-null for grouped queries; identifies the group across reconnects. + */ public data class QueryChannelsSpec( val filter: FilterObject, val querySort: QuerySorter, + val cids: Set = emptySet(), + val groupKey: String? = null, ) { - var groupKey: String? = null - var cids: Set = emptySet() + + /** + * Two-argument constructor preserved for source and binary compatibility with prior versions + * of this class (when [cids] and [groupKey] were mutable body fields). + */ + public constructor( + filter: FilterObject, + querySort: QuerySorter, + ) : this(filter, querySort, emptySet(), null) + + /** + * Two-argument [copy] preserved for binary compatibility. Existing bytecode that referenced the + * pre-refactor 2-arg `copy(filter, querySort)` continues to resolve through this method. + * [cids] and [groupKey] are carried over from the receiver. + * + * Source callers using `spec.copy(filter = x)` resolve here (more applicable overload than the + * auto-generated 4-arg copy), so cids/groupKey are preserved automatically. + */ + public fun copy( + filter: FilterObject = this.filter, + querySort: QuerySorter = this.querySort, + ): QueryChannelsSpec = QueryChannelsSpec(filter, querySort, cids, groupKey) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt index 2377e680ef3..4a08bd58e89 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt @@ -17,12 +17,14 @@ package io.getstream.chat.android.client import io.getstream.chat.android.client.chatclient.BaseChatClientTest +import io.getstream.chat.android.client.plugin.Plugin import io.getstream.chat.android.client.utils.RetroError import io.getstream.chat.android.client.utils.RetroSuccess import io.getstream.chat.android.client.utils.verifyNetworkError import io.getstream.chat.android.client.utils.verifySuccess import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.positiveRandomInt import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomInt @@ -31,6 +33,9 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever /** @@ -48,6 +53,8 @@ internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { groupKey = key, channels = listOf(randomChannel()), unreadChannels = randomInt(), + next = randomString(), + prev = randomString(), ) }, ), @@ -74,12 +81,49 @@ internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { verifyNetworkError(result, errorCode) } + @Test + fun `queryGroupedChannels dispatches result to plugin listeners`() = runTest { + // given + val plugin: Plugin = mock() + plugins.add(plugin) + val groupedChannels = GroupedChannels( + groups = mapOf( + "direct" to GroupedChannelsGroup( + groupKey = "direct", + channels = listOf(randomChannel()), + unreadChannels = randomInt(), + next = randomString(), + prev = null, + ), + ), + ) + val sut = Fixture() + .givenQueryGroupedChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall()) + .get() + val groupsParam = mapOf("direct" to GroupedChannelsGroupQuery(limit = 25, next = "cursor")) + // when + sut.queryGroupedChannels( + limit = 30, + groups = groupsParam, + watch = true, + presence = false, + ).await() + // then + verify(plugin).onQueryGroupedChannelsResult( + result = any(), + limit = eq(30), + groups = eq(groupsParam), + watch = eq(true), + presence = eq(false), + ) + } + internal inner class Fixture { fun givenQueryGroupedChannelsResult( result: io.getstream.result.call.Call, ) = apply { - whenever(api.queryGroupedChannels(anyOrNull(), any(), any())).thenReturn(result) + whenever(api.queryGroupedChannels(anyOrNull(), anyOrNull(), any(), any())).thenReturn(result) } fun get(): ChatClient = chatClient 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 9914135e74a..0bc6998efde 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 @@ -1911,10 +1911,11 @@ internal class MoshiChatApiTest { val connectionId = randomString() val limit = randomInt() sut.setConnection(userId = userId, connectionId = connectionId) - val result = sut.queryGroupedChannels(limit = limit, watch = false, presence = false).await() + val result = sut.queryGroupedChannels(limit = limit, groups = null, watch = false, presence = false).await() // then val expectedPayload = QueryGroupedChannelsRequest( limit = limit, + groups = null, watch = false, presence = false, ) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index b2d5091c8b8..5da2c26654a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -457,6 +457,8 @@ internal object MoshiChatApiTestArguments { ), ), unread_channels = randomInt(), + next = null, + prev = null, ), ), duration = "12ms", diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 8a9e71b6e81..15356e649b0 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4894,8 +4894,10 @@ public final class io/getstream/chat/android/compose/viewmodel/channel/ChannelIn public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel : androidx/lifecycle/ViewModel { public static final field $stable I - public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;Z)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun deleteConversation (Lio/getstream/chat/android/models/Channel;)V public final fun dismissChannelAction ()V @@ -4913,7 +4915,6 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final fun muteChannel (Lio/getstream/chat/android/models/Channel;)V public final fun performChannelAction (Lio/getstream/chat/android/ui/common/state/channels/actions/ChannelAction;)V public final fun pinChannel (Lio/getstream/chat/android/models/Channel;)V - public final fun prefill (Lio/getstream/chat/android/models/GroupedChannelsGroup;)V public final fun refresh ()V public final fun selectChannel (Lio/getstream/chat/android/models/Channel;)V public final fun setFilters (Lio/getstream/chat/android/models/FilterObject;)V @@ -4928,8 +4929,25 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public static final field $stable I public fun ()V - public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;Z)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;I)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;Z)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;I)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;Z)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index e2723d5047e..269795e8dc5 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.compose.state.QueryConfig import io.getstream.chat.android.compose.state.channels.list.ChannelsState import io.getstream.chat.android.compose.state.channels.list.ItemState @@ -37,7 +38,7 @@ import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters -import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.User @@ -47,7 +48,6 @@ import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.extensions.globalStateFlow import io.getstream.chat.android.state.extensions.initQueryChannelsAsState -import io.getstream.chat.android.state.extensions.prefillQueryChannels import io.getstream.chat.android.state.extensions.queryChannelsAsState import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData @@ -80,10 +80,12 @@ import kotlin.coroutines.cancellation.CancellationException * A state store that represents all the information required to query, filter, show and react to * [Channel] items in a list. * + * Two query modes are supported: + * - **Standard**: classical offset-based channel list driven by a filter and sort. + * - **Grouped**: cursor-paginated list backed by `queryGroupedChannels`. Identified by a + * `groupKey` returned from the server; channels appear under this group automatically. + * * @param chatClient Used to connect to the API. - * @param initialSort The initial sort used for [Channel]s. - * @param initialFilters The current data filter. Users can change this state using [setFilters] to - * impact which data is shown on the UI. * @param channelLimit How many channels we fetch per page. * @param memberLimit How many members are fetched for each channel item when loading channels. * When `null`, the server-side default is used. @@ -94,26 +96,89 @@ import kotlin.coroutines.cancellation.CancellationException * @param isDraftMessageEnabled If the draft message feature is enabled. * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. * @param globalState A flow emitting the current [GlobalState]. - * @param skipInitialQuery When `true`, the ViewModel will not perform the initial queryChannels API call. - * The channel list state can then be populated via [prefill]. Defaults to `false`. */ @OptIn(ExperimentalCoroutinesApi::class) -@Suppress("TooManyFunctions") -public class ChannelListViewModel( +@Suppress("TooManyFunctions", "LongParameterList") +public class ChannelListViewModel internal constructor( public val chatClient: ChatClient, - initialSort: QuerySorter = QuerySortByField.descByName("last_updated"), - initialFilters: FilterObject? = null, - private val channelLimit: Int = DEFAULT_CHANNEL_LIMIT, - private val memberLimit: Int? = null, - private val messageLimit: Int? = null, - private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), - searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, - private val isDraftMessageEnabled: Boolean = false, - private val messageSearchSort: QuerySorter? = null, - private val globalState: Flow = chatClient.globalStateFlow, - private val skipInitialQuery: Boolean = false, + private val mode: QueryMode, + private val channelLimit: Int, + private val memberLimit: Int?, + private val messageLimit: Int?, + private val chatEventHandlerFactory: ChatEventHandlerFactory, + searchDebounceMs: Long, + private val isDraftMessageEnabled: Boolean, + private val messageSearchSort: QuerySorter?, + private val globalState: Flow, ) : ViewModel() { + /** + * Standard channel list constructor. Performs an initial `queryChannels` request driven by + * [initialFilters] / [initialSort]. + */ + public constructor( + chatClient: ChatClient, + initialSort: QuerySorter = QuerySortByField.descByName("last_updated"), + initialFilters: FilterObject? = null, + channelLimit: Int = DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + globalState: Flow = chatClient.globalStateFlow, + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilters, initialSort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = searchDebounceMs, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + globalState = globalState, + ) + + /** + * Grouped channel list constructor. Subscribes to the state identified by [groupKey] without + * issuing a remote call; the state is populated externally by `queryGroupedChannels` responses. + */ + public constructor( + chatClient: ChatClient, + groupKey: String, + channelLimit: Int = DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + globalState: Flow = chatClient.globalStateFlow, + ) : this( + chatClient = chatClient, + mode = QueryMode.Grouped(groupKey), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = searchDebounceMs, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + globalState = globalState, + ) + + /** Internal discriminator for the two query modes supported by this ViewModel. */ + internal sealed interface QueryMode { + data class Standard( + val initialFilter: FilterObject?, + val initialSort: QuerySorter, + ) : QueryMode + + data class Grouped(val groupKey: String) : QueryMode + } + private val logger by taggedLogger("Chat:ChannelListVM") /** @@ -138,13 +203,18 @@ public class ChannelListViewModel( /** * State flow that keeps the value of the current [FilterObject] for channels. + * Only meaningful in [QueryMode.Standard]; held but unused in [QueryMode.Grouped]. */ - private val filterFlow: MutableStateFlow = MutableStateFlow(initialFilters) + private val filterFlow: MutableStateFlow = + MutableStateFlow((mode as? QueryMode.Standard)?.initialFilter) /** * State flow that keeps the value of the current [QuerySorter] for channels. + * Only meaningful in [QueryMode.Standard]; held but unused in [QueryMode.Grouped]. */ - private val querySortFlow: MutableStateFlow> = MutableStateFlow(initialSort) + private val querySortFlow: MutableStateFlow> = MutableStateFlow( + (mode as? QueryMode.Standard)?.initialSort ?: QuerySortByField.descByName("last_updated"), + ) /** * The currently active query configuration, stored in a [MutableStateFlow]. It's created using @@ -243,11 +313,28 @@ public class ChannelListViewModel( */ private val searchMessageState: MutableStateFlow = MutableStateFlow(null) + /** + * Emits the effective query input to react to. Standard mode reacts to filter/sort changes + * (via [queryConfigFlow]) in addition to search and refresh; Grouped mode has fixed + * filter/sort, so it only reacts to search and refresh. + * + * Declared before the [init] block: `viewModelScope` uses `Dispatchers.Main.immediate`, which + * starts the launched body synchronously via the unconfined event loop while still inside the + * constructor — so any field referenced from `init()` must be initialised before the init + * block runs, or it is `null` at access time. + */ + private val activeQuery: Flow = when (mode) { + is QueryMode.Standard -> + combine(_searchQuery, queryConfigFlow, refreshFlow) { query, _, _ -> query } + is QueryMode.Grouped -> + combine(_searchQuery, refreshFlow) { query, _ -> query } + } + /** * Combines the latest search query and filter to fetch channels and emit them to the UI. */ init { - if (initialFilters == null) { + if (mode is QueryMode.Standard && mode.initialFilter == null) { viewModelScope.launch { val filter = buildDefaultFilter().first() @@ -262,36 +349,69 @@ public class ChannelListViewModel( /** * Makes the initial query to request channels and starts observing state changes. + * + * Three dispatch branches for channel queries: + * - **Standard**: build a standard `QueryChannelsRequest` from filter/sort and issue + * `queryChannelsAsState`. + * - **Grouped + no active channel search**: subscribe to the identifier-keyed state via + * `initQueryChannelsAsState`. No remote call; `queryGroupedChannels` responses populate + * the state via the listener. + * - **Grouped + active channel search**: fall back to a standalone `queryChannelsAsState` + * using [optimizedChannelSearchFilter]. */ private suspend fun init() { logger.d { "[init] no args" } - combine(_searchQuery, queryConfigFlow, refreshFlow) { query, config, ts -> Triple(query, config, ts) } - .collectLatest { (query, config, ts) -> - logger.i { "[observeInit] ts: $ts, query: $query, config: $config" } - when (query) { - is SearchQuery.Empty -> { - searchScope.coroutineContext.cancelChildren() - if (skipInitialQuery) { - observeInitQueryChannels(config) - } else { - observeQueryChannels(config) - } - } - is SearchQuery.Channels -> { - searchScope.coroutineContext.cancelChildren() - observeQueryChannels( - config.copy( - filters = createQueryChannelsFilter(config.filters, query.query), - ), - ) - } - is SearchQuery.Messages -> { - chListScope.coroutineContext.cancelChildren() - handleSearchQuery(query.query) - observeSearchMessages(query.query) + activeQuery.collectLatest { query -> + logger.i { "[observeInit] query: $query" } + when (query) { + is SearchQuery.Empty, + is SearchQuery.Channels, + -> { + searchScope.coroutineContext.cancelChildren() + when (mode) { + is QueryMode.Standard -> + observeQueryChannels(query.query) + is QueryMode.Grouped -> + if (query.query.length >= MIN_CHANNEL_SEARCH_QUERY_LENGTH) { + observeQueryChannels(query.query) + } else { + observeGroupedChannels(mode.groupKey) + } } } + is SearchQuery.Messages -> { + chListScope.coroutineContext.cancelChildren() + handleSearchQuery(query.query) + observeSearchMessages(query.query) + } } + } + } + + /** + * Builds the [QueryChannelsRequest] that backs the channel list for the given [searchQuery]. + * - Standard: wraps the current filter with [createQueryChannelsFilter] to apply the search. + * Returns `null` if no filter has been resolved yet. + * - Grouped: always uses [optimizedChannelSearchFilter] — the caller (`init`) only invokes + * `observeQueryChannels` in Grouped mode when a search query is active. + */ + private fun buildQueryChannelsRequest(searchQuery: String): QueryChannelsRequest? = when (mode) { + is QueryMode.Standard -> { + val baseFilter = filterFlow.value ?: return null + QueryChannelsRequest( + filter = createQueryChannelsFilter(baseFilter, searchQuery), + querySort = querySortFlow.value, + limit = channelLimit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + } + is QueryMode.Grouped -> QueryChannelsRequest( + filter = optimizedChannelSearchFilter(searchQuery), + limit = channelLimit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) } private suspend fun observeSearchMessages(query: String) = runCatching { @@ -414,12 +534,13 @@ public class ChannelListViewModel( } /** - * Creates a [QueryChannelsState] without triggering an API call and starts collecting from it. - * Used when [skipInitialQuery] is `true` — the state can be populated later via [prefill]. + * Creates a [QueryChannelsState] by issuing a remote queryChannels request built from the + * given [searchQuery] (via [buildQueryChannelsRequest]) and starts collecting from it. */ - private fun observeInitQueryChannels(config: QueryConfig) = - observeQueryChannelsInternal(config, tag = "observeInitQueryChannels") { request -> - chatClient.initQueryChannelsAsState( + private fun observeQueryChannels(searchQuery: String) = + observeQueryChannelsInternal(tag = "observeQueryChannels") { + val request = buildQueryChannelsRequest(searchQuery) ?: return@observeQueryChannelsInternal null + chatClient.queryChannelsAsState( request = request, chatEventHandlerFactory = chatEventHandlerFactory, coroutineScope = chListScope, @@ -427,37 +548,29 @@ public class ChannelListViewModel( } /** - * Creates a [QueryChannelsState] by triggering an API call and starts collecting from it. + * Subscribes to the identifier-keyed [QueryChannelsState] for the Grouped variant identified + * by [groupKey], without triggering a remote API call. State is populated externally by + * `queryGroupedChannels` responses routed through the listener. */ - @Suppress("LongMethod") - private fun observeQueryChannels(config: QueryConfig) = - observeQueryChannelsInternal(config, tag = "observeQueryChannels") { request -> - chatClient.queryChannelsAsState( - request = request, + private fun observeGroupedChannels(groupKey: String) = + observeQueryChannelsInternal(tag = "observeGroupedChannels") { + chatClient.initQueryChannelsAsState( + identifier = QueryChannelsIdentifier.Grouped(groupKey), chatEventHandlerFactory = chatEventHandlerFactory, coroutineScope = chListScope, ) } /** - * Shared implementation for observing a [QueryChannelsState]. - * The [createState] lambda determines how the state is created (with or without an API call). + * Shared implementation for observing a [QueryChannelsState] from a [createState] producer. */ @Suppress("LongMethod") private fun observeQueryChannelsInternal( - config: QueryConfig, tag: String, - createState: (QueryChannelsRequest) -> StateFlow, + createState: () -> StateFlow?, ) = runCatching { queryChannelDebouncer.submitSuspendable { - val queryChannelsRequest = QueryChannelsRequest( - filter = config.filters, - querySort = config.querySort, - limit = channelLimit, - messageLimit = messageLimit, - memberLimit = memberLimit, - ) - queryChannelsState = createState(queryChannelsRequest) + queryChannelsState = createState() ?: return@submitSuspendable queryChannelsState.filterNotNull().collectLatest { queryChannelsState -> combine( queryChannelsState.channelsStateData, @@ -544,36 +657,11 @@ public class ChannelListViewModel( ) } - /** - * Injects fresh channel data into the channel list, replacing any previously loaded data. - * Channels are persisted to the local database for offline recovery. After prefill, - * pagination ([loadMore]) works normally with the correct offset. - * - * Requires [skipInitialQuery] to be `true`. Can be called at any time after ViewModel - * creation — if the state is not yet initialized, the call suspends until it is ready. - * - * @param group The [GroupedChannelsGroup] containing the channels and group key. - * The group key identifies which group this list belongs to. - */ - public fun prefill(group: GroupedChannelsGroup) { - logger.d { "[prefill] channels.size: ${group.channels.size}, groupKey: ${group.groupKey}" } - if (!skipInitialQuery) { - logger.w { "[prefill] rejected (skipInitialQuery is false)" } - return - } - chListScope.launch { - val filter = filterFlow.filterNotNull().first() - val sort = querySortFlow.value - val request = QueryChannelsRequest( - filter = filter, - querySort = sort, - limit = channelLimit, - messageLimit = messageLimit, - memberLimit = memberLimit, - ) - chatClient.prefillQueryChannels(request, group) - } - } + private fun optimizedChannelSearchFilter(text: String): FilterObject = + Filters.and( + Filters.autocomplete("name", text), + Filters.`in`("members", user.value?.id.orEmpty()), + ) /** * Refreshes either channels or search results. @@ -618,10 +706,15 @@ public class ChannelListViewModel( * Use this if you need to support runtime filter changes, through custom filters UI. * * Warning: The filter that's applied will override the `initialFilters` set through the constructor. + * No-op in Grouped mode — the group's filter is fixed. * * @param newFilters The new filters to be used as a baseline for filtering channels. */ public fun setFilters(newFilters: FilterObject) { + if (mode is QueryMode.Grouped) { + logger.w { "[setFilters] no-op in Grouped mode (groupKey: ${mode.groupKey})" } + return + } this.filterFlow.tryEmit(value = newFilters) } @@ -629,8 +722,13 @@ public class ChannelListViewModel( * Allows for the change of the query sort used for channel queries. * * Use this if you need to support runtime sort changes, through custom sort UI. + * No-op in Grouped mode — the group's sort is fixed. */ public fun setQuerySort(querySort: QuerySorter) { + if (mode is QueryMode.Grouped) { + logger.w { "[setQuerySort] no-op in Grouped mode (groupKey: ${mode.groupKey})" } + return + } this.querySortFlow.tryEmit(value = querySort) } @@ -655,11 +753,15 @@ public class ChannelListViewModel( private suspend fun loadMoreQueryChannels() { logger.d { "[loadMoreQueryChannels] no args" } - val currentFilter = filterFlow.value - if (currentFilter == null) { - logger.v { "[loadMoreQueryChannels] rejected (no current filter)" } + + // Grouped + no active channel search uses cursor pagination via queryGroupedChannels. + if (mode is QueryMode.Grouped && + _searchQuery.value.query.length < MIN_CHANNEL_SEARCH_QUERY_LENGTH + ) { + loadMoreGroupedChannels(mode.groupKey) return } + val currentQuery = queryChannelsState.value?.nextPageRequest?.value if (currentQuery == null) { logger.v { "[loadMoreQueryChannels] rejected (no current query)" } @@ -673,10 +775,21 @@ public class ChannelListViewModel( logger.v { "[loadMoreQueryChannels] rejected (already loading more)" } return } - val nextQuery = currentQuery.copy( - filter = createQueryChannelsFilter(currentFilter, _searchQuery.value.query), - querySort = querySortFlow.value, - ) + val nextQuery = when (mode) { + is QueryMode.Standard -> { + val currentFilter = filterFlow.value ?: run { + logger.v { "[loadMoreQueryChannels] rejected (no current filter)" } + return + } + currentQuery.copy( + filter = createQueryChannelsFilter(currentFilter, _searchQuery.value.query), + querySort = querySortFlow.value, + ) + } + // Grouped + active channel search re-issues the existing search request with the + // bumped offset; filter/sort on the request are already correct. + is QueryMode.Grouped -> currentQuery + } logger.v { "[loadMoreQueryChannels] offset: ${nextQuery.offset}, limit: ${nextQuery.limit}" } channelsState = channelsState.copy(isLoadingMore = true) val result = chatClient.queryChannels(nextQuery).await() @@ -688,6 +801,38 @@ public class ChannelListViewModel( channelsState = channelsState.copy(isLoadingMore = false) } + private suspend fun loadMoreGroupedChannels(groupKey: String) { + logger.d { "[loadMoreGroupedChannels] groupKey: $groupKey" } + val state = queryChannelsState.value + if (state == null) { + logger.v { "[loadMoreGroupedChannels] rejected (no current state)" } + return + } + val cursor = state.nextCursor.value + if (cursor == null) { + logger.v { "[loadMoreGroupedChannels] rejected (no next cursor)" } + return + } + if (channelsState.endOfChannels) { + logger.v { "[loadMoreGroupedChannels] rejected (end of channels)" } + return + } + if (channelsState.isLoadingMore) { + logger.v { "[loadMoreGroupedChannels] rejected (already loading more)" } + return + } + channelsState = channelsState.copy(isLoadingMore = true) + val result = chatClient.queryGroupedChannels( + groups = mapOf(groupKey to GroupedChannelsGroupQuery(next = cursor)), + ).await() + if (result.isSuccess) { + logger.v { "[loadMoreGroupedChannels] completed (listener applied)" } + } else { + logger.e { "[loadMoreGroupedChannels] failed: ${result.errorOrNull()}" } + } + channelsState = channelsState.copy(isLoadingMore = false) + } + /** * Clears the active action if we've chosen [Cancel], otherwise, stores the selected action as * the currently active action, in [activeChannelAction]. @@ -833,7 +978,7 @@ public class ChannelListViewModel( /** * Debounce time for search queries. */ - private const val SEARCH_DEBOUNCE_MS = 300L + internal const val SEARCH_DEBOUNCE_MS = 300L /** * Minimum length of the search query to start searching for channels. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index fc2106842a3..9c49d0f6c9b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.viewmodel.channels import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel.QueryMode import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Message @@ -26,37 +27,103 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.globalStateFlow /** * Builds the factory that contains all the dependencies required for the Channels Screen. * It currently provides the [ChannelListViewModel] using those dependencies. * - * @param chatClient The client used to fetch data. - * @param querySort The sorting order for channels. - * @param filters The base filters used to filter out channels. - * @param channelLimit How many channels we fetch per page. - * @param memberLimit How many members are fetched for each channel item when loading channels. - * When `null`, the server-side default is used. - * @param messageLimit How many messages are fetched for each channel item when loading channels. - * When `null`, the server-side default is used. - * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. - * @param messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. - * @param skipInitialQuery When `true`, the ViewModel will not perform the initial queryChannels API call. - * The channel list state can then be populated via [ChannelListViewModel.prefill]. Defaults to `false`. + * Two construction modes are supported: + * - **Standard**: filter + sort drive a classical `queryChannels` request. + * - **Grouped**: a `groupKey` identifies a state populated by `queryGroupedChannels` responses. + * + * Pick the constructor that matches the mode you want — mixing filter/sort with a `groupKey` is + * not supported. */ -public class ChannelViewModelFactory( - private val chatClient: ChatClient = ChatClient.instance(), - private val querySort: QuerySorter = QuerySortByField.descByName("last_updated"), - private val filters: FilterObject? = null, - private val channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, - private val memberLimit: Int? = null, - private val messageLimit: Int? = null, - private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), - private val isDraftMessageEnabled: Boolean = false, - private val messageSearchSort: QuerySorter? = null, - private val skipInitialQuery: Boolean = false, +@Suppress("LongParameterList") +public class ChannelViewModelFactory internal constructor( + private val chatClient: ChatClient, + private val mode: QueryMode, + private val channelLimit: Int, + private val memberLimit: Int?, + private val messageLimit: Int?, + private val chatEventHandlerFactory: ChatEventHandlerFactory, + private val isDraftMessageEnabled: Boolean, + private val messageSearchSort: QuerySorter?, ) : ViewModelProvider.Factory { + /** + * Standard [ChannelListViewModel] factory. + * + * @param chatClient The client used to fetch data. + * @param querySort The sorting order for channels. + * @param filters The base filters used to filter out channels. + * @param channelLimit How many channels we fetch per page. + * @param memberLimit How many members are fetched for each channel item when loading channels. + * When `null`, the server-side default is used. + * @param messageLimit How many messages are fetched for each channel item when loading channels. + * When `null`, the server-side default is used. + * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Optional sorting for message search results. + * When `null`, the server-side default is used. + */ + @JvmOverloads + public constructor( + chatClient: ChatClient = ChatClient.instance(), + querySort: QuerySorter = QuerySortByField.descByName("last_updated"), + filters: FilterObject? = null, + channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilter = filters, initialSort = querySort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + ) + + /** + * Grouped [ChannelListViewModel] factory. Wires the ViewModel to the state identified by + * [groupKey] without firing a remote call; `queryGroupedChannels` responses populate it. + * + * @param chatClient The client used to fetch data. + * @param groupKey Identifies the group whose state this ViewModel observes. + * @param channelLimit How many channels we fetch per page. + * @param memberLimit Members fetched per channel. When `null`, server-side default is used. + * @param messageLimit Messages fetched per channel. When `null`, server-side default is used. + * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Optional sorting for message search results. + */ + @JvmOverloads + public constructor( + chatClient: ChatClient = ChatClient.instance(), + groupKey: String, + channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Grouped(groupKey), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + ) + /** * Create a new instance of [ChannelListViewModel] class. */ @@ -67,15 +134,15 @@ public class ChannelViewModelFactory( @Suppress("UNCHECKED_CAST") return ChannelListViewModel( chatClient = chatClient, - initialSort = querySort, - initialFilters = filters, + mode = mode, channelLimit = channelLimit, - messageLimit = messageLimit, memberLimit = memberLimit, + messageLimit = messageLimit, chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = ChannelListViewModel.SEARCH_DEBOUNCE_MS, isDraftMessageEnabled = isDraftMessageEnabled, messageSearchSort = messageSearchSort, - skipInitialQuery = skipInitialQuery, + globalState = chatClient.globalStateFlow, ) as T } } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index bb6709139b6..18111074407 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -495,110 +495,18 @@ internal class ChannelListViewModelTest { } @Test - fun `Given skipInitialQuery is true When ViewModel initializes Should not call queryChannels`() = + fun `Given groupKey ViewModel When initializing Should not call queryChannels`() = runTest { val chatClient: ChatClient = mock() Fixture(chatClient) .givenCurrentUser() .givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) .givenChannelMutes() - .get(this, skipInitialQuery = true) + .get(this, groupKey = "team-a") verify(chatClient, times(0)).queryChannels(any()) } - @Test - fun `Given skipInitialQuery is true When prefill is called Should show prefilled channels`() = - runTest { - val chatClient: ChatClient = mock() - val channelsStateData = MutableStateFlow(ChannelsStateData.Loading) - val loadingFlow = MutableStateFlow(true) - val endOfChannelsFlow = MutableStateFlow(false) - val nextPageRequestFlow = MutableStateFlow(null) - val queryChannelsState: QueryChannelsState = mock { - whenever(it.channelsStateData) doReturn channelsStateData - whenever(it.channels) doReturn MutableStateFlow(null) - whenever(it.loading) doReturn loadingFlow - whenever(it.loadingMore) doReturn MutableStateFlow(false) - whenever(it.endOfChannels) doReturn endOfChannelsFlow - whenever(it.nextPageRequest) doReturn nextPageRequestFlow - } - val viewModel = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsState(queryChannelsState) - .givenChannelMutes() - .get(this, skipInitialQuery = true) - - assertTrue(viewModel.channelsState.isLoading) - - // Simulate what prefillQueryChannels does to the state - channelsStateData.value = ChannelsStateData.Result(listOf(channel1, channel2)) - loadingFlow.value = false - advanceUntilIdle() - - assertFalse(viewModel.channelsState.isLoading) - assertEquals(2, viewModel.channelsState.channelItems.size) - verify(chatClient, times(0)).queryChannels(any()) - } - - @Test - fun `Given skipInitialQuery is false When ViewModel initializes Should call queryChannels normally`() = - runTest { - val chatClient: ChatClient = mock() - Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1)), - loading = false, - ) - .givenChannelMutes() - .get(this) - - verify(chatClient, times(1)).queryChannels(any()) - } - - @Test - fun `Given skipInitialQuery is true and prefill not called When showing channels Should show loading state`() = - runTest { - val chatClient: ChatClient = mock() - val viewModel = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) - .givenChannelMutes() - .get(this, skipInitialQuery = true) - - assertTrue(viewModel.channelsState.isLoading) - assertEquals(0, viewModel.channelsState.channelItems.size) - } - - @Test - fun `Given skipInitialQuery is true When loadMore after prefill Should use nextPageRequest offset`() = - runTest { - val chatClient: ChatClient = mock() - val nextPageRequest = QueryChannelsRequest( - filter = queryFilter, - offset = 20, - limit = 30, - querySort = querySort, - ) - val viewModel = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1)), - loading = false, - nextPageRequest = nextPageRequest, - ) - .givenChannelMutes() - .get(this, skipInitialQuery = true) - - viewModel.loadMore() - advanceUntilIdle() - - verify(chatClient).queryChannels(nextPageRequest) - } - private class Fixture( private val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), @@ -684,6 +592,7 @@ internal class ChannelListViewModelTest { loadingMore: Boolean = false, endOfChannels: Boolean = false, nextPageRequest: QueryChannelsRequest? = null, + nextCursor: String? = null, ) = apply { val queryChannelsState: QueryChannelsState = mock { whenever(it.channelsStateData) doReturn MutableStateFlow(channelsStateData) @@ -692,25 +601,46 @@ internal class ChannelListViewModelTest { whenever(it.loadingMore) doReturn MutableStateFlow(loadingMore) whenever(it.endOfChannels) doReturn MutableStateFlow(endOfChannels) whenever(it.nextPageRequest) doReturn MutableStateFlow(nextPageRequest) + whenever(it.nextCursor) doReturn MutableStateFlow(nextCursor) } whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState + whenever( + stateRegistry.queryChannels( + any(), + ), + ) doReturn queryChannelsState } fun givenChannelsState(queryChannelsState: QueryChannelsState) = apply { whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState - } - - fun get(testScope: TestScope, skipInitialQuery: Boolean = false): ChannelListViewModel { - val channelListViewModel = ChannelListViewModel( - chatClient = chatClient, - initialSort = initialSort, - initialFilters = initialFilters, - isDraftMessageEnabled = false, - chatEventHandlerFactory = ChatEventHandlerFactory(clientState), - messageSearchSort = messageSearchSort, - globalState = MutableStateFlow(globalState), - skipInitialQuery = skipInitialQuery, - ) + whenever( + stateRegistry.queryChannels( + any(), + ), + ) doReturn queryChannelsState + } + + fun get(testScope: TestScope, groupKey: String? = null): ChannelListViewModel { + val channelListViewModel = if (groupKey != null) { + ChannelListViewModel( + chatClient = chatClient, + groupKey = groupKey, + isDraftMessageEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } else { + ChannelListViewModel( + chatClient = chatClient, + initialSort = initialSort, + initialFilters = initialFilters, + isDraftMessageEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } testScope.advanceUntilIdle() return channelListViewModel } diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 5858d32f1e5..468b7094a70 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1093,21 +1093,42 @@ public final class io/getstream/chat/android/models/GroupedChannels { } public final class io/getstream/chat/android/models/GroupedChannelsGroup { - public fun (Ljava/lang/String;Ljava/util/List;I)V - public synthetic fun (Ljava/lang/String;Ljava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/List;ILjava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;ILjava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/util/List; public final fun component3 ()I - public final fun copy (Ljava/lang/String;Ljava/util/List;I)Lio/getstream/chat/android/models/GroupedChannelsGroup; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/lang/String;Ljava/util/List;IILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/util/List;ILjava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/lang/String;Ljava/util/List;ILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; public fun equals (Ljava/lang/Object;)Z public final fun getChannels ()Ljava/util/List; public final fun getGroupKey ()Ljava/lang/String; + public final fun getNext ()Ljava/lang/String; + public final fun getPrev ()Ljava/lang/String; public final fun getUnreadChannels ()I public fun hashCode ()I public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/models/GroupedChannelsGroupQuery { + public fun ()V + public fun (Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Integer; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/models/GroupedChannelsGroupQuery; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroupQuery;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroupQuery; + public fun equals (Ljava/lang/Object;)Z + public final fun getLimit ()Ljava/lang/Integer; + public final fun getNext ()Ljava/lang/String; + public final fun getPrev ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/models/GuestUser { public fun (Lio/getstream/chat/android/models/User;Ljava/lang/String;)V public final fun component1 ()Lio/getstream/chat/android/models/User; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt index a5f258e484f..8810c5a32ad 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt @@ -19,18 +19,37 @@ package io.getstream.chat.android.models /** * A grouped channels response returned by [ChatClient.queryGroupedChannels]. * - * @param groups The channel groups returned by the backend in response order. + * @param groups The channel groups returned by the backend, keyed by group name. */ public data class GroupedChannels(public val groups: Map) /** * A channel group returned by [ChatClient.queryGroupedChannels]. * + * @param groupKey The name of the group. * @param channels The channels that belong to this group. * @param unreadChannels The total unread channel count in the group. + * @param next Cursor for the next page of this group, or `null` if there is no further page. + * @param prev Cursor for the previous page of this group, or `null` if there is none. */ public data class GroupedChannelsGroup( public val groupKey: String, public val channels: List, public val unreadChannels: Int = 0, + public val next: String? = null, + public val prev: String? = null, +) + +/** + * Per-group request options for [ChatClient.queryGroupedChannels]. + * + * @param limit Max channels for this group. `null` falls back to the request-level limit + * (which, in turn, falls back to the server default when also `null`). + * @param next Cursor for the next page of this group. Mutually exclusive with [prev]. + * @param prev Cursor for the previous page of this group. Mutually exclusive with [next]. + */ +public data class GroupedChannelsGroupQuery( + public val limit: Int? = null, + public val next: String? = null, + public val prev: String? = null, ) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt index e66c9b566a0..1d89b7eca61 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt @@ -88,7 +88,7 @@ import io.getstream.chat.android.offline.repository.domain.user.internal.UserEnt ThreadOrderEntity::class, DraftMessageEntity::class, ], - version = 98, + version = 99, exportSchema = false, ) @TypeConverters( diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt index 8d479ca12cb..0dd64ebb003 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt @@ -16,11 +16,10 @@ package io.getstream.chat.android.offline.repository.domain.queryChannels.internal +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository import io.getstream.chat.android.client.query.QueryChannelsSpec -import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.FilterObject -import io.getstream.chat.android.models.querysort.QuerySorter /** * Repository for queries of channels. This implementation uses the database. @@ -38,19 +37,8 @@ internal class DatabaseQueryChannelsRepository( queryChannelsDao.insert(toEntity(queryChannelsSpec)) } - /** - * Selects by a filter and query sort. - * - * @param filter [FilterObject] - * @param querySort [QuerySorter] - */ - override suspend fun selectBy( - filter: FilterObject, - querySort: QuerySorter, - groupKey: String?, - ): QueryChannelsSpec? { - val id = groupKey ?: generateId(filter, querySort) - return queryChannelsDao.select(id)?.let(Companion::toModel) + override suspend fun selectBy(identifier: QueryChannelsIdentifier): QueryChannelsSpec? { + return queryChannelsDao.select(generateId(identifier))?.let(Companion::toModel) } override suspend fun clear() { @@ -58,22 +46,26 @@ internal class DatabaseQueryChannelsRepository( } private companion object { - private fun generateId(filter: FilterObject, querySort: QuerySorter): String { - return "${filter.hashCode()}-${querySort.toDto().hashCode()}" + private fun generateId(identifier: QueryChannelsIdentifier): String = when (identifier) { + is QueryChannelsIdentifier.Standard -> + "${identifier.filter.hashCode()}-${identifier.sort.toDto().hashCode()}" + is QueryChannelsIdentifier.Grouped -> + "grp:${identifier.group}" } - private fun toEntity(queryChannelsSpec: QueryChannelsSpec): QueryChannelsEntity = - QueryChannelsEntity( - queryChannelsSpec.groupKey ?: generateId(queryChannelsSpec.filter, queryChannelsSpec.querySort), - queryChannelsSpec.filter, - queryChannelsSpec.querySort, - queryChannelsSpec.cids.toList(), - ) + private fun toEntity(spec: QueryChannelsSpec): QueryChannelsEntity = QueryChannelsEntity( + id = generateId(spec.identifier), + filter = spec.filter, + querySort = spec.querySort, + cids = spec.cids.toList(), + groupKey = spec.groupKey, + ) - private fun toModel(queryChannelsEntity: QueryChannelsEntity): QueryChannelsSpec = - QueryChannelsSpec( - queryChannelsEntity.filter, - queryChannelsEntity.querySort, - ).apply { cids = queryChannelsEntity.cids.toSet() } + private fun toModel(entity: QueryChannelsEntity): QueryChannelsSpec = QueryChannelsSpec( + filter = entity.filter, + querySort = entity.querySort, + cids = entity.cids.toSet(), + groupKey = entity.groupKey, + ) } } diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt index 30b03b70c76..7dc649efc76 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt @@ -29,6 +29,7 @@ internal data class QueryChannelsEntity( val filter: FilterObject, val querySort: QuerySorter, val cids: List, + val groupKey: String? = null, ) internal const val QUERY_CHANNELS_ENTITY_TABLE_NAME = "stream_channel_query" diff --git a/stream-chat-android-state/api/stream-chat-android-state.api b/stream-chat-android-state/api/stream-chat-android-state.api index f8beb0ff13d..6ae393877ed 100644 --- a/stream-chat-android-state/api/stream-chat-android-state.api +++ b/stream-chat-android-state/api/stream-chat-android-state.api @@ -228,6 +228,7 @@ public abstract interface class io/getstream/chat/android/state/plugin/state/que public abstract fun getFilter ()Lio/getstream/chat/android/models/FilterObject; public abstract fun getLoading ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getLoadingMore ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getNextCursor ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getNextPageRequest ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getRecoveryNeeded ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getSort ()Lio/getstream/chat/android/models/querysort/QuerySorter; diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index 3de410857a2..891ed64cd2b 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.internal.validateCidWithResult import io.getstream.chat.android.client.utils.message.isEphemeral @@ -35,7 +36,6 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler @@ -149,52 +149,33 @@ public fun ChatClient.queryChannelsAsState( } /** - * Creates a [QueryChannelsState] for the given [request] without triggering a remote queryChannels - * API call or loading from the local cache. The returned state starts in a loading state and can be - * populated later via [prefillQueryChannels]. + * Creates a [QueryChannelsState] for the given grouped [identifier] without triggering a remote + * queryChannels API call. Channels cached under the identifier's DB key are loaded optimistically + * so the UI can render immediately while the next `queryGroupedChannels` response populates the + * state via the listener. * - * Use this together with [prefillQueryChannels] when channel data is obtained from an external source - * (e.g., a grouped channels endpoint) and the default queryChannels call should be skipped. + * Only [QueryChannelsIdentifier.Grouped] is accepted — the standard offset-paginated path is + * served by [queryChannelsAsState] instead. * - * @param request The request's parameters combined into [QueryChannelsRequest] class. + * @param identifier The grouped query's identifier whose state should be initialized. * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] that will be used to create [ChatEventHandler]. * @param coroutineScope The [CoroutineScope] used for executing the request. * - * @return A StateFlow object that emits a null when the user has not been connected yet and the new [QueryChannelsState] when the user changes. + * @return A StateFlow that emits null until the user is connected, then emits the [QueryChannelsState] for the identifier. */ @InternalStreamChatApi @JvmOverloads public fun ChatClient.initQueryChannelsAsState( - request: QueryChannelsRequest, + identifier: QueryChannelsIdentifier.Grouped, chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(clientState), coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), ): StateFlow { - StreamLog.d(TAG) { "[initQueryChannelsAsState] request: $request" } + StreamLog.d(TAG) { "[initQueryChannelsAsState] identifier: $identifier" } return getStateOrNull(coroutineScope) { - requestsAsState(coroutineScope).initQueryChannelsState(request, chatEventHandlerFactory) + requestsAsState(coroutineScope).initQueryChannelsState(identifier, chatEventHandlerFactory) } } -/** - * Injects [channels] into the [QueryChannelsState] identified by the [request]'s filter and sort. - * The channels replace any existing data and are persisted to the local database. - * No remote API call is made. - * - * The state must have been previously created via [initQueryChannelsAsState] or [queryChannelsAsState]. - * - * @param request The [QueryChannelsRequest] identifying the query to populate. - * @param group The [GroupedChannelsGroup] containing the channels and group key. - */ -@InternalStreamChatApi -public suspend fun ChatClient.prefillQueryChannels( - request: QueryChannelsRequest, - group: GroupedChannelsGroup, -) { - StreamLog.d(TAG) { "[prefillQueryChannels] channels.size: ${group.channels.size}, groupKey: ${group.groupKey}" } - clientState.user.first { it != null } - logic.queryChannels(request).prefillChannels(group, request) -} - /** * Performs [ChatClient.queryChannel] with watch = true under the hood and returns [ChannelState] associated with the query. * The [ChannelState] cannot be created before connecting the user therefore, the method returns a StateFlow diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt index 67edaf4586a..80a983f7f7b 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt @@ -116,7 +116,7 @@ public class StatePlugin internal constructor( ) : Plugin, QueryMembersListener by QueryMembersListenerState(logic), QueryChannelsListener by QueryChannelsListenerState(logic, queryingChannelsFree), - QueryGroupedChannelsListener by QueryGroupedChannelsListenerState(mutableGlobalState), + QueryGroupedChannelsListener by QueryGroupedChannelsListenerState(logic, mutableGlobalState), QueryChannelListener by QueryChannelListenerState(logic), ThreadQueryListener by ThreadQueryListenerState(logic, repositoryFacade), ChannelMarkReadListener by ChannelMarkReadListenerState(stateRegistry), diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt index 40e3fc5b1f3..8c325c43d30 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt @@ -16,26 +16,42 @@ package io.getstream.chat.android.state.plugin.listener.internal +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.plugin.listeners.QueryGroupedChannelsListener import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery +import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.result.Result internal class QueryGroupedChannelsListenerState( + private val logic: LogicRegistry, private val globalState: MutableGlobalState, ) : QueryGroupedChannelsListener { override suspend fun onQueryGroupedChannelsResult( result: Result, limit: Int?, + groups: Map?, watch: Boolean, presence: Boolean, ) { - if (result is Result.Success) { - val groupedUnreadChannels = result.value.groups.mapValues { (_, group) -> - group.unreadChannels - } - globalState.setGroupedUnreadChannels(groupedUnreadChannels) + if (result !is Result.Success) return + + // The request may include any subset of groups (pagination, custom per-group limits, + // or the default set). Always merge the returned counts into the existing map so groups + // not present in this response retain their previous counts. + val returnedUnreadCounts = result.value.groups.mapValues { (_, group) -> group.unreadChannels } + val merged = globalState.groupedUnreadChannels.value + returnedUnreadCounts + globalState.setGroupedUnreadChannels(merged) + + // Route each returned group's channels into the per-group state. + result.value.groups.forEach { (key, group) -> + // A request without a `next` cursor for this key (or no per-group query at all) is + // a first-page request → replace channels. With a `next` cursor → paginated → append. + val isFirstPage = groups?.get(key)?.next == null + logic.queryChannels(QueryChannelsIdentifier.Grouped(key)) + .applyGroupedResult(group, isFirstPage = isFirstPage) } } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt index b346340cb8d..b5b68c0c457 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt @@ -21,6 +21,8 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelStateLogicProvider import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.Channel @@ -66,17 +68,20 @@ internal class LogicRegistry internal constructor( private val now: () -> Long, ) : ChannelStateLogicProvider { - private val queryChannels: ConcurrentHashMap>, QueryChannelsLogic> = + private val queryChannels: ConcurrentHashMap = ConcurrentHashMap() private val channels: ConcurrentHashMap, ChannelLogic> = ConcurrentHashMap() private val queryThreads: ConcurrentHashMap?>, QueryThreadsLogic> = ConcurrentHashMap() private val threads: ConcurrentHashMap = ConcurrentHashMap() - internal fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsLogic { - return queryChannels.getOrPut(filter to sort) { + internal fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsLogic = + queryChannels(QueryChannelsIdentifier.Standard(filter, sort)) + + internal fun queryChannels(identifier: QueryChannelsIdentifier): QueryChannelsLogic { + return queryChannels.getOrPut(identifier) { val queryChannelsStateLogic = QueryChannelsStateLogic( - mutableState = stateRegistry.queryChannels(filter, sort).toMutableState(), + mutableState = stateRegistry.queryChannels(identifier).toMutableState(), stateRegistry = stateRegistry, logicRegistry = this, coroutineScope = coroutineScope, @@ -90,8 +95,7 @@ internal class LogicRegistry internal constructor( ) QueryChannelsLogic( - filter, - sort, + identifier, client, queryChannelsStateLogic, queryChannelsDatabaseLogic, @@ -101,7 +105,7 @@ internal class LogicRegistry internal constructor( /** Returns [QueryChannelsLogic] accordingly to [QueryChannelsRequest]. */ internal fun queryChannels(queryChannelsRequest: QueryChannelsRequest): QueryChannelsLogic = - queryChannels(queryChannelsRequest.filter, queryChannelsRequest.querySort) + queryChannels(queryChannelsRequest.identifier) /** Returns [ChannelLogic] by channelType and channelId combination. */ fun channel(channelType: String, channelId: String): ChannelLogic { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt index 46dce1814ab..9552020ea21 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.extensions.internal.applyPagination +import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.client.persistance.repository.ChannelConfigRepository import io.getstream.chat.android.client.persistance.repository.ChannelRepository import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository @@ -51,7 +52,7 @@ internal class QueryChannelsDatabaseLogic( queryChannelsSpec: QueryChannelsSpec?, ): List? { val cachedSpec = queryChannelsSpec?.let { - queryChannelsRepository.selectBy(it.filter, it.querySort, it.groupKey) + queryChannelsRepository.selectBy(it.identifier) } return if (cachedSpec != null) { // Spec is present in DB, fetch channels according to it diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index b36de5c6262..92d797e58c6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -20,6 +20,7 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.CidEvent +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.client.query.request.ChannelFilterRequest.filterWithOffset import io.getstream.chat.android.models.Channel @@ -27,7 +28,6 @@ import io.getstream.chat.android.models.ChannelConfig import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.User -import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.log.taggedLogger @@ -39,8 +39,7 @@ private const val CHANNEL_LIMIT = 30 @Suppress("TooManyFunctions") internal class QueryChannelsLogic( - private val filter: FilterObject, - private val sort: QuerySorter, + private val identifier: QueryChannelsIdentifier, private val client: ChatClient, private val queryChannelsStateLogic: QueryChannelsStateLogic, private val queryChannelsDatabaseLogic: QueryChannelsDatabaseLogic, @@ -51,16 +50,14 @@ internal class QueryChannelsLogic( /** * Sets the current request and optimistically loads any cached channels for the given * [request] from the local database. The cached channels are added to the in-memory state. - * Does NOT update the channels offset — callers that use this to seed state before a - * [prefillChannels] call can rely on prefill to set the correct offset. * No remote API call is made. */ internal suspend fun loadOfflineChannels(request: QueryChannelsRequest) { setCurrentRequest(request) val offlineChannels = fetchChannelsFromCache(request.toOfflinePaginationRequest(), queryChannelsDatabaseLogic) - // fetchChannelsFromCache suspends for DB I/O. During that suspension, a concurrent - // prefillChannels call may have already populated the state. Check after the DB read - // to avoid appending stale offline data on top of fresh prefilled channels. + // fetchChannelsFromCache suspends for DB I/O. During that suspension, fresh data may have + // landed via another path. Check after the DB read to avoid appending stale offline data on + // top of fresh channels. val existing = queryChannelsStateLogic.getChannels() if (!existing.isNullOrEmpty()) { logger.d { "[loadOfflineChannels] skipped (channels already populated: ${existing.size})" } @@ -75,6 +72,33 @@ internal class QueryChannelsLogic( queryChannelsStateLogic.setLoadingFirstPage(false) } + /** + * Grouped-only offline cache read. Called from the Grouped init flow. Standard's + * [loadOfflineChannels] is untouched. + * + * Reads channels stored under the stable identifier-derived id and seeds in-memory state, + * guarding against the case where a concurrent [applyGroupedResult] call has already populated + * the state with fresh data. + */ + internal suspend fun loadOfflineGroupedChannels() { + if (identifier !is QueryChannelsIdentifier.Grouped) { + logger.w { "[loadOfflineGroupedChannels] rejected (non-Grouped identifier: $identifier)" } + return + } + val pagination = AnyChannelPaginationRequest().apply { + channelOffset = 0 + channelLimit = CHANNEL_LIMIT + } + val cachedChannels = fetchChannelsFromCache(pagination, queryChannelsDatabaseLogic) + val existing = queryChannelsStateLogic.getChannels() + if (existing.isNullOrEmpty() && !cachedChannels.isNullOrEmpty()) { + logger.d { "[loadOfflineGroupedChannels] showing ${cachedChannels.size} cached channels" } + queryChannelsStateLogic.addChannelsState(cachedChannels) + } + queryChannelsStateLogic.initializeChannelsIfNeeded() + queryChannelsStateLogic.setLoadingFirstPage(false) + } + internal suspend fun queryOffline(pagination: AnyChannelPaginationRequest) { if (queryChannelsStateLogic.isLoading()) { logger.i { "[queryOffline] another query channels request is in progress. Ignoring this request." } @@ -109,9 +133,12 @@ internal class QueryChannelsLogic( queryChannelsStateLogic.setCurrentRequest(request) } - internal fun filter(): FilterObject = filter + internal fun filter(): FilterObject = when (identifier) { + is QueryChannelsIdentifier.Standard -> identifier.filter + is QueryChannelsIdentifier.Grouped -> queryChannelsStateLogic.getState().filter + } - internal fun groupKey(): String? = queryChannelsStateLogic.getGroupKey() + internal fun groupKey(): String? = (identifier as? QueryChannelsIdentifier.Grouped)?.group internal fun currentRequest(): QueryChannelsRequest? = queryChannelsStateLogic.getState().currentRequest.value @@ -177,54 +204,37 @@ internal class QueryChannelsLogic( } /** - * Replaces the current query's channels with the provided [group]'s channels and persists - * the result to the local database. No remote API call is made. - * - * Before writing fresh data, an optimistic offline load is performed: cached channels from - * a prior session are read from the DB using the stable [GroupedChannelsGroup.groupKey] and - * shown immediately. This mirrors iOS where the CoreData observer re-evaluates its predicate - * as soon as `query.groupKey` is set. + * Applies a [GroupedChannelsGroup] response payload to this query's state. + * Replaces channels on the first page, appends on subsequent pages. + * Updates the next-page cursor and persists fresh data to the local database. */ - internal suspend fun prefillChannels( - group: GroupedChannelsGroup, - request: QueryChannelsRequest, - ) { - val groupKey = group.groupKey + internal suspend fun applyGroupedResult(group: GroupedChannelsGroup, isFirstPage: Boolean) { + if (identifier !is QueryChannelsIdentifier.Grouped) { + logger.w { "[applyGroupedResult] rejected (non-Grouped identifier: $identifier)" } + return + } val channels = group.channels - logger.d { "[prefillChannels] channels.size: ${channels.size}, groupKey: $groupKey" } - - // 1. Set groupKey so all DB operations use the stable key. - // Also signals SyncManager to route reconnect through queryGroupedChannels. - queryChannelsStateLogic.setGroupKey(groupKey) - - // 2. Set current request (needed for nextPageRequest derivation used by loadMore) - queryChannelsStateLogic.setCurrentRequest(request) - - // 3. Optimistic offline load: read cached channels from DB using the stable groupKey. - val cachedChannels = fetchChannelsFromCache( - request.toOfflinePaginationRequest(), - queryChannelsDatabaseLogic, - ) - if (!cachedChannels.isNullOrEmpty()) { - logger.d { "[prefillChannels] showing ${cachedChannels.size} cached channels" } - queryChannelsStateLogic.addChannelsState(cachedChannels) - queryChannelsStateLogic.setLoadingFirstPage(false) + logger.d { + "[applyGroupedResult] channels.size: ${channels.size}, isFirstPage: $isFirstPage, " + + "next: ${group.next}" } - // 4. Replace with fresh channels from the API - val existingChannels = queryChannelsStateLogic.getChannels() - if (!existingChannels.isNullOrEmpty()) { - queryChannelsStateLogic.removeChannels(existingChannels.keys) + if (isFirstPage) { + val existing = queryChannelsStateLogic.getChannels() + if (!existing.isNullOrEmpty()) { + queryChannelsStateLogic.removeChannels(existing.keys) + } + queryChannelsStateLogic.setCids(emptySet()) } - queryChannelsStateLogic.getQuerySpecs().cids = emptySet() + queryChannelsStateLogic.addChannelsState(channels) - queryChannelsStateLogic.setChannelsOffset(channels.size) - queryChannelsStateLogic.setEndOfChannels(channels.isEmpty()) + queryChannelsStateLogic.setNextCursor(group.next) + queryChannelsStateLogic.setEndOfChannels(group.next == null) queryChannelsStateLogic.setLoadingFirstPage(false) queryChannelsStateLogic.setLoadingMore(false) queryChannelsStateLogic.setRecoveryNeeded(false) - // 5. Persist fresh data to DB under the stable groupKey + // Persist queryChannelsDatabaseLogic.insertQueryChannels(queryChannelsStateLogic.getQuerySpecs()) val channelConfigs = channels.map { ChannelConfig(it.type, it.config) } queryChannelsDatabaseLogic.insertChannelConfigs(channelConfigs) @@ -246,27 +256,36 @@ internal class QueryChannelsLogic( } /** - * Runs [QueryChannelsRequest] which is querying the first page. + * Runs [QueryChannelsRequest] which is querying the first page. No-op for grouped identifiers — + * the grouped path uses `queryGroupedChannels` instead. */ internal suspend fun queryFirstPage(): Result> { logger.d { "[queryFirstPage] no args" } - val currentRequest = queryChannelsStateLogic.getState().currentRequest.value - val messageLimit = currentRequest?.messageLimit - val memberLimit = currentRequest?.memberLimit - val request = QueryChannelsRequest( - filter = filter, - offset = INITIAL_CHANNEL_OFFSET, - limit = CHANNEL_LIMIT, - querySort = sort, - messageLimit = messageLimit, - memberLimit = memberLimit, - ) - - queryChannelsStateLogic.setCurrentRequest(request) - - return client.queryChannelsInternal(request) - .await() - .also { onQueryChannelsResult(it, request) } + return when (identifier) { + is QueryChannelsIdentifier.Standard -> { + val currentRequest = queryChannelsStateLogic.getState().currentRequest.value + val messageLimit = currentRequest?.messageLimit + val memberLimit = currentRequest?.memberLimit + val request = QueryChannelsRequest( + filter = identifier.filter, + offset = INITIAL_CHANNEL_OFFSET, + limit = CHANNEL_LIMIT, + querySort = identifier.sort, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + + queryChannelsStateLogic.setCurrentRequest(request) + + client.queryChannelsInternal(request) + .await() + .also { onQueryChannelsResult(it, request) } + } + is QueryChannelsIdentifier.Grouped -> { + logger.v { "[queryFirstPage] no-op for Grouped identifier" } + Result.Success(emptyList()) + } + } } private suspend fun onOnlineQueryResult(result: Result>, request: QueryChannelsRequest) { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index cb391a8aaec..48b44045da6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -124,11 +124,12 @@ internal class QueryChannelsStateLogic( mutableState.setChannelsOffset(offset) } - internal fun getGroupKey(): String? = mutableState.groupKey + internal fun setNextCursor(cursor: String?) { + mutableState.setNextCursor(cursor) + } - internal fun setGroupKey(key: String?) { - mutableState.groupKey = key - mutableState.queryChannelsSpec.groupKey = key + internal fun setCids(cids: Set) { + mutableState.setCids(cids) } /** @@ -149,7 +150,7 @@ internal class QueryChannelsStateLogic( * @param channels List. */ internal suspend fun addChannelsState(channels: List) { - mutableState.queryChannelsSpec.cids += channels.map { it.cid } + mutableState.setCids(mutableState.queryChannelsSpec.cids + channels.map { it.cid }) val existingChannels = mutableState.rawChannels ?: emptyMap() mutableState.setChannels( existingChannels + @@ -204,7 +205,7 @@ internal class QueryChannelsStateLogic( logger.w { "[removeChannels] rejected (existingChannels is null)" } return } - mutableState.queryChannelsSpec.cids = mutableState.queryChannelsSpec.cids - cidSet + mutableState.setCids(mutableState.queryChannelsSpec.cids - cidSet) mutableState.setChannels(existingChannels - cidSet) } @@ -231,7 +232,7 @@ internal class QueryChannelsStateLogic( * into the query map. */ internal fun trackChannel(channel: Channel) { - mutableState.queryChannelsSpec.cids += channel.cid + mutableState.setCids(mutableState.queryChannelsSpec.cids + channel.cid) val existingChannels = mutableState.rawChannels ?: emptyMap() mutableState.setChannels(existingChannels + (channel.cid to channel)) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt index 26ba7cacacf..9bf8d1f6ec5 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt @@ -20,6 +20,8 @@ import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.events.ChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Location @@ -67,7 +69,7 @@ public class StateRegistry( private val logger by taggedLogger("Chat:StateRegistry") - private val queryChannels: ConcurrentHashMap>, QueryChannelsMutableState> = + private val queryChannels: ConcurrentHashMap = ConcurrentHashMap() private val channels: ConcurrentHashMap, ChannelMutableState> = ConcurrentHashMap() private val queryThreads: ConcurrentHashMap>, QueryThreadsMutableState> = @@ -84,9 +86,17 @@ public class StateRegistry( * * @return [QueryChannelsState] object. */ - public fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsState { - return queryChannels.getOrPut(filter to sort) { - QueryChannelsMutableState(filter, sort, scope, latestUsers, activeLiveLocations) + public fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsState = + queryChannels(QueryChannelsIdentifier.Standard(filter, sort)) + + /** + * Returns [QueryChannelsState] associated with the given [identifier]. + * Creates a fresh state if no entry exists for the identifier yet. + */ + @InternalStreamChatApi + public fun queryChannels(identifier: QueryChannelsIdentifier): QueryChannelsState { + return queryChannels.getOrPut(identifier) { + QueryChannelsMutableState(identifier, scope, latestUsers, activeLiveLocations) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt index 31dbfd8a52c..76173867eb3 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt @@ -22,8 +22,10 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.internal.logic import io.getstream.chat.android.state.extensions.state import io.getstream.chat.android.state.model.querychannels.pagination.internal.QueryChannelPaginationRequest import io.getstream.chat.android.state.plugin.state.StateRegistry @@ -71,21 +73,22 @@ internal class ChatClientStateCalls( } /** - * Creates or retrieves the [QueryChannelsState] for the given [request] without launching - * a remote queryChannels API call, and optimistically populates it with any channels - * cached in the local database. The state can be further populated later via - * [ChatClient.prefillQueryChannels]. + * Creates or retrieves the [QueryChannelsState] for the given grouped [identifier] without + * launching a remote queryChannels API call. Channels cached under the identifier's DB key + * are optimistically loaded into the state. */ internal suspend fun initQueryChannelsState( - request: QueryChannelsRequest, + identifier: QueryChannelsIdentifier.Grouped, chatEventHandlerFactory: ChatEventHandlerFactory, ): QueryChannelsState { - logger.d { "[initQueryChannelsState] request: $request" } + logger.d { "[initQueryChannelsState] identifier: $identifier" } chatClient.clientState.user.first { it != null } - return deferredState + val state = deferredState .await() - .queryChannels(request.filter, request.querySort) + .queryChannels(identifier) .apply { this.chatEventHandlerFactory = chatEventHandlerFactory } + chatClient.logic.queryChannels(identifier).loadOfflineGroupedChannels() + return state } /** Reference request of the channel query. */ diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt index 932e3f8460e..92f42cc2545 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt @@ -52,6 +52,12 @@ public interface QueryChannelsState { /** If the current state reached the final page. */ public val endOfChannels: StateFlow + /** + * Cursor for the next page when this state belongs to a grouped query. + * `null` means there is no further page (or that this is a standard query that doesn't use cursors). + */ + public val nextCursor: StateFlow + /** * The collection of channels loaded by the query channels request. * The StateFlow is initialized with null which means that channels are not loaded yet. diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt index 538a3bb3e80..98ac51bb0e1 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt @@ -20,11 +20,14 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.extensions.internal.updateLiveLocations import io.getstream.chat.android.client.extensions.internal.updateUsers +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult @@ -40,21 +43,55 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn internal class QueryChannelsMutableState( - override val filter: FilterObject, - override val sort: QuerySorter, + internal val identifier: QueryChannelsIdentifier, scope: CoroutineScope, latestUsers: StateFlow>, activeLiveLocations: StateFlow>, ) : QueryChannelsState { + override val filter: FilterObject = when (identifier) { + is QueryChannelsIdentifier.Standard -> identifier.filter + is QueryChannelsIdentifier.Grouped -> Filters.neutral() + } + + override val sort: QuerySorter = when (identifier) { + is QueryChannelsIdentifier.Standard -> identifier.sort + is QueryChannelsIdentifier.Grouped -> QuerySortByField.descByName("last_message_at") + } + internal var rawChannels: Map? get() = _channels?.value private set(value) { _channels?.value = value } - // This is needed for queries - internal val queryChannelsSpec: QueryChannelsSpec = QueryChannelsSpec(filter, sort) + // Mutable backing field — replaced (immutably) by setCids(). + private var _querySpec: QueryChannelsSpec = QueryChannelsSpec( + filter = filter, + querySort = sort, + cids = emptySet(), + groupKey = (identifier as? QueryChannelsIdentifier.Grouped)?.group, + ) + + /** + * Snapshot of the spec backing this state. Returned reference is immutable; use [setCids] to + * update. + */ + internal val queryChannelsSpec: QueryChannelsSpec + get() = _querySpec + + /** + * Replaces the held spec with a copy whose [QueryChannelsSpec.cids] is [cids]. + * [QueryChannelsSpec.filter], [QueryChannelsSpec.querySort], and [QueryChannelsSpec.groupKey] are preserved. + */ + internal fun setCids(cids: Set) { + _querySpec = QueryChannelsSpec( + filter = _querySpec.filter, + querySort = _querySpec.querySort, + cids = cids, + groupKey = _querySpec.groupKey, + ) + } /** * Property that exposes a map of raw channels. @@ -87,12 +124,7 @@ internal class QueryChannelsMutableState( private var _channelsOffset: MutableStateFlow? = MutableStateFlow(0) internal val channelsOffset: StateFlow = _channelsOffset!! - /** - * The group key from [io.getstream.chat.android.models.GroupedChannels] that this query - * was populated from. Non-null means this query uses grouped channels for sync instead - * of individual queryChannels. - */ - internal var groupKey: String? = null + private var _nextCursor: MutableStateFlow? = MutableStateFlow(null) override var chatEventHandlerFactory: ChatEventHandlerFactory? = null set(value) { @@ -119,6 +151,7 @@ internal class QueryChannelsMutableState( override val loading: StateFlow = _loading!! override val loadingMore: StateFlow = _loadingMore!! override val endOfChannels: StateFlow = _endOfChannels!! + override val nextCursor: StateFlow = _nextCursor!! override val channels: StateFlow?> = sortedChannels override val channelsStateData: StateFlow = loading.combine(sortedChannels) { loading: Boolean, channels: List? -> @@ -188,6 +221,14 @@ internal class QueryChannelsMutableState( rawChannels = channelsMap } + /** + * Set the next-page cursor. Used by the grouped-channels path; the standard path doesn't + * publish a cursor here. + */ + fun setNextCursor(cursor: String?) { + _nextCursor?.value = cursor + } + fun destroy() { _channels = null _loading = null @@ -196,7 +237,7 @@ internal class QueryChannelsMutableState( _currentRequest = null _recoveryNeeded = null _channelsOffset = null - groupKey = null + _nextCursor = null } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index fc080fc4239..f391580bc1a 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -49,7 +49,6 @@ import io.getstream.chat.android.models.Reaction import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.TimeDuration import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry -import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.log.taggedLogger @@ -439,47 +438,25 @@ internal class SyncManager( } /** - * For [QueryChannelsLogic] instances populated via grouped channels ([prefill][QueryChannelsLogic.prefillChannels]), - * calls [ChatClient.queryGroupedChannels] once and re-prefills each with fresh data. + * Drives the recovery flow for grouped channel queries: when at least one active grouped + * logic needs recovery, calls [ChatClient.queryGroupedChannels] once. The + * [io.getstream.chat.android.state.plugin.listener.internal.QueryGroupedChannelsListenerState] + * routes the response into the corresponding per-group state and persists it. */ private suspend fun updateGroupedQueryChannels(recoverAll: Boolean) { - val groupedLogics = logicRegistry.getActiveQueryChannelsLogic() - .filter { it.groupKey() != null } - .filter { it.recoveryNeeded().value || recoverAll } + val hasGroupedRecovery = logicRegistry.getActiveQueryChannelsLogic() + .any { it.groupKey() != null && (it.recoveryNeeded().value || recoverAll) } - if (groupedLogics.isEmpty()) { + if (!hasGroupedRecovery) { logger.v { "[updateGroupedQueryChannels] no grouped queries to restore" } return } - logger.d { "[updateGroupedQueryChannels] groupedLogics.size: ${groupedLogics.size}" } - - val groupKeyToLogic = mutableMapOf() - groupedLogics.forEach { logic -> - val key = logic.groupKey() ?: return@forEach - groupKeyToLogic[key] = logic - } when (val result = chatClient.queryGroupedChannels().await()) { - is Result.Success -> { - val grouped = result.value - - groupKeyToLogic.forEach { (key, logic) -> - val group = grouped.groups[key] ?: return@forEach - - val currentRequest = logic.currentRequest() - if (currentRequest != null) { - logic.prefillChannels(group, currentRequest) - } else { - logger.w { - "[updateGroupedQueryChannels] no current request for group '$key', skipping prefill" - } - } - } - logger.v { "[updateGroupedQueryChannels] succeeded" } - } - is Result.Failure -> { + is Result.Success -> + logger.v { "[updateGroupedQueryChannels] succeeded (listener applied)" } + is Result.Failure -> logger.e { "[updateGroupedQueryChannels] queryGroupedChannels failed: ${result.value}" } - } } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt index d9deb6af2f8..00e54ee7efb 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt @@ -16,67 +16,192 @@ package io.getstream.chat.android.state.plugin.listener.internal +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.models.GroupedChannelsGroupQuery +import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.result.Error import io.getstream.result.Result +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test -import org.mockito.Mockito.mock import org.mockito.kotlin.any import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever internal class QueryGroupedChannelsListenerStateTest { + private val queryChannelsLogic: QueryChannelsLogic = mock() + private val logic: LogicRegistry = mock { + on(it.queryChannels(any())) doReturn queryChannelsLogic + } private val globalState: MutableGlobalState = mock() - private val listener = QueryGroupedChannelsListenerState(globalState) + private val listener = QueryGroupedChannelsListenerState(logic, globalState) @Test - fun `when result is successful, grouped unread channels should be set on global state`() = runTest { + fun `successful result merges returned unread counts into existing global state`() = runTest { // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow( + mapOf("direct" to 4, "support" to 1), + ) doNothing().`when`(globalState).setGroupedUnreadChannels(any()) val result = Result.Success( value = GroupedChannels( groups = mapOf( - "direct" to GroupedChannelsGroup(groupKey = "direct", channels = emptyList(), unreadChannels = 3), - "support" to GroupedChannelsGroup(groupKey = "support", channels = emptyList(), unreadChannels = 1), + "support" to GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 7, + next = null, + prev = null, + ), ), ), ) // when - listener.onQueryGroupedChannelsResult(result, limit = null, watch = false, presence = false) - // then - verify(globalState, times(1)).setGroupedUnreadChannels(mapOf("direct" to 3, "support" to 1)) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery(next = "cursor")), + watch = false, + presence = false, + ) + // then - merged: direct stays at 4, support updated to 7 + verify(globalState, times(1)).setGroupedUnreadChannels( + mapOf("direct" to 4, "support" to 7), + ) } @Test - fun `when result is successful with null unread channels, defaults to zero`() = runTest { + fun `successful result with groups null merges into existing state`() = runTest { // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow( + mapOf("direct" to 4), + ) doNothing().`when`(globalState).setGroupedUnreadChannels(any()) val result = Result.Success( value = GroupedChannels( groups = mapOf( - "expired" to GroupedChannelsGroup(groupKey = "expired", channels = emptyList(), unreadChannels = 0), + "direct" to GroupedChannelsGroup( + groupKey = "direct", + channels = emptyList(), + unreadChannels = 2, + next = null, + prev = null, + ), + "support" to GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 0, + next = null, + prev = null, + ), ), ), ) - // when - listener.onQueryGroupedChannelsResult(result, limit = 10, watch = true, presence = false) - // then - verify(globalState, times(1)).setGroupedUnreadChannels(mapOf("expired" to 0)) + // when - groups param is null (default set requested) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = null, + watch = false, + presence = false, + ) + // then - direct updated to 2, support added with 0; merging preserves any pre-existing keys + verify(globalState, times(1)).setGroupedUnreadChannels( + mapOf("direct" to 2, "support" to 0), + ) } @Test - fun `when result is failure, global state should not be updated`() = runTest { + fun `failure result does not touch global state`() = runTest { // given - val result = Result.Failure(Error.GenericError("Network error")) + val result = Result.Failure(Error.GenericError("network")) // when - listener.onQueryGroupedChannelsResult(result, limit = null, watch = false, presence = false) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = null, + watch = false, + presence = false, + ) // then verify(globalState, never()).setGroupedUnreadChannels(any()) } + + @Test + fun `success routes each returned group to the matching Grouped identifier as first page when no next cursor was requested`() = + runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow(emptyMap()) + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val groupDirect = GroupedChannelsGroup( + groupKey = "direct", + channels = emptyList(), + unreadChannels = 0, + next = null, + prev = null, + ) + val groupSupport = GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 0, + next = "cursor-support", + prev = null, + ) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf("direct" to groupDirect, "support" to groupSupport), + ), + ) + // when - groups param is null (default set requested → both treated as first page) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = null, + watch = false, + presence = false, + ) + // then + verify(logic).queryChannels(eq(QueryChannelsIdentifier.Grouped("direct"))) + verify(logic).queryChannels(eq(QueryChannelsIdentifier.Grouped("support"))) + verify(queryChannelsLogic).applyGroupedResult(groupDirect, isFirstPage = true) + verify(queryChannelsLogic).applyGroupedResult(groupSupport, isFirstPage = true) + } + + @Test + fun `success treats keys with requested next cursor as paginated`() = runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow(emptyMap()) + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val groupSupport = GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 0, + next = null, + prev = null, + ) + val result = Result.Success( + value = GroupedChannels(groups = mapOf("support" to groupSupport)), + ) + // when - the request passed a next cursor for "support" + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery(next = "cursor")), + watch = false, + presence = false, + ) + // then + verify(queryChannelsLogic).applyGroupedResult(groupSupport, isFirstPage = false) + } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt index 0b0545d788f..798afab3d94 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.persistance.repository.ChannelConfigRepository import io.getstream.chat.android.client.persistance.repository.ChannelRepository import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository @@ -89,14 +90,14 @@ internal class QueryChannelsDatabaseLogicTest { val pagination = AnyChannelPaginationRequest() val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) - whenever(queryChannelsRepository.selectBy(filter, sort, null)) doReturn null + whenever(queryChannelsRepository.selectBy(QueryChannelsIdentifier.Standard(filter, sort))) doReturn null // When val result = logic.fetchChannelsFromCache(pagination, queryChannelsSpec) // Then assertNull(result) - verify(queryChannelsRepository).selectBy(filter, sort, null) + verify(queryChannelsRepository).selectBy(QueryChannelsIdentifier.Standard(filter, sort)) } @Test @@ -125,7 +126,7 @@ internal class QueryChannelsDatabaseLogicTest { val channel3 = randomChannel(id = "channel3", type = "messaging") val expectedChannels = listOf(channel1, channel2, channel3) - whenever(queryChannelsRepository.selectBy(filter, sort, null)) doReturn cachedSpec + whenever(queryChannelsRepository.selectBy(QueryChannelsIdentifier.Standard(filter, sort))) doReturn cachedSpec whenever(repositoryFacade.selectChannels(listOf(cid1, cid2, cid3), pagination)) doReturn expectedChannels // When @@ -133,7 +134,7 @@ internal class QueryChannelsDatabaseLogicTest { // Then assertEquals(expectedChannels, result) - verify(queryChannelsRepository).selectBy(filter, sort, null) + verify(queryChannelsRepository).selectBy(QueryChannelsIdentifier.Standard(filter, sort)) verify(repositoryFacade).selectChannels(listOf(cid1, cid2, cid3), pagination) } @@ -151,7 +152,7 @@ internal class QueryChannelsDatabaseLogicTest { ) val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) - whenever(queryChannelsRepository.selectBy(filter, sort, null)) doReturn cachedSpec + whenever(queryChannelsRepository.selectBy(QueryChannelsIdentifier.Standard(filter, sort))) doReturn cachedSpec whenever(repositoryFacade.selectChannels(emptyList(), pagination)) doReturn emptyList() // When @@ -159,7 +160,7 @@ internal class QueryChannelsDatabaseLogicTest { // Then assertEquals(emptyList(), result) - verify(queryChannelsRepository).selectBy(filter, sort, null) + verify(queryChannelsRepository).selectBy(QueryChannelsIdentifier.Standard(filter, sort)) verify(repositoryFacade).selectChannels(emptyList(), pagination) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index e2491c2439b..b341f9d61a2 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.client.test.randomNewMessageEvent @@ -75,8 +76,7 @@ internal class QueryChannelsLogicTest { whenever(queryChannelsStateLogic.getQuerySpecs()) doReturn queryChannelsSpec logic = QueryChannelsLogic( - filter = filter, - sort = sort, + identifier = QueryChannelsIdentifier.Standard(filter, sort), client = client, queryChannelsStateLogic = queryChannelsStateLogic, queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, @@ -417,79 +417,20 @@ internal class QueryChannelsLogicTest { // endregion - // region prefillChannels - - @Test - fun `prefillChannels replaces existing channels and updates all state`() = runTest { - // Given - val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) - val oldChannel = randomChannel(id = "old") - val existingChannels = mapOf(oldChannel.cid to oldChannel) - val newChannels = listOf(randomChannel(id = "new1"), randomChannel(id = "new2"), randomChannel(id = "new3")) - val group = GroupedChannelsGroup(groupKey = "key", channels = newChannels) - whenever(queryChannelsStateLogic.getChannels()) doReturn existingChannels - - // When - logic.prefillChannels(group, request) - - // Then - verify(queryChannelsStateLogic).setGroupKey("key") - verify(queryChannelsStateLogic).setCurrentRequest(request) - verify(queryChannelsStateLogic).removeChannels(existingChannels.keys) - verify(queryChannelsStateLogic).addChannelsState(newChannels) - verify(queryChannelsStateLogic).setChannelsOffset(3) - verify(queryChannelsStateLogic).setEndOfChannels(false) - verify(queryChannelsStateLogic).setLoadingFirstPage(false) - verify(queryChannelsStateLogic).setLoadingMore(false) - verify(queryChannelsStateLogic).setRecoveryNeeded(false) - } + // region applyGroupedResult @Test - fun `prefillChannels with empty list marks end of channels`() = runTest { - // Given - val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) - val group = GroupedChannelsGroup(groupKey = "key", channels = emptyList()) - whenever(queryChannelsStateLogic.getChannels()) doReturn null - - // When - logic.prefillChannels(group, request) - - // Then - verify(queryChannelsStateLogic).setChannelsOffset(0) - verify(queryChannelsStateLogic).setEndOfChannels(true) - } - - @Test - fun `prefillChannels skips remove when no existing channels`() = runTest { - // Given - val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) - val newChannels = listOf(randomChannel()) - val group = GroupedChannelsGroup(groupKey = "key", channels = newChannels) - whenever(queryChannelsStateLogic.getChannels()) doReturn null - - // When - logic.prefillChannels(group, request) - - // Then - verify(queryChannelsStateLogic, never()).removeChannels(any()) - verify(queryChannelsStateLogic).addChannelsState(newChannels) - } - - @Test - fun `prefillChannels persists to database`() = runTest { - // Given - val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) - val channels = listOf(randomChannel(), randomChannel()) + fun `applyGroupedResult is a no-op on non-Grouped identifiers`() = runTest { + // Given — logic is constructed with a Standard identifier in setUp. + val channels = listOf(randomChannel(id = "new1")) val group = GroupedChannelsGroup(groupKey = "key", channels = channels) - whenever(queryChannelsStateLogic.getChannels()) doReturn null // When - logic.prefillChannels(group, request) + logic.applyGroupedResult(group, isFirstPage = true) - // Then - verify(queryChannelsDatabaseLogic).insertQueryChannels(queryChannelsSpec) - verify(queryChannelsDatabaseLogic).insertChannelConfigs(any()) - verify(queryChannelsDatabaseLogic).storeStateForChannels(channels.toSet()) + // Then — no state mutations on a non-Grouped logic. + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic, never()).setNextCursor(any()) } // endregion diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt index 3fc42fccc24..235ebb52bd7 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt @@ -36,7 +36,6 @@ import io.getstream.chat.android.state.plugin.state.querychannels.internal.Query import io.getstream.chat.android.test.TestCoroutineRule import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import org.amshove.kluent.`should contain same` import org.junit.Rule import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull @@ -60,10 +59,7 @@ internal class QueryChannelsStateLogicTest { private val testCid = (type to id).toCid() private val queryChannelsSpec = - QueryChannelsSpec(Filters.neutral(), QuerySortByField.descByName("")) - .apply { - cids = setOf(testCid) - } + QueryChannelsSpec(Filters.neutral(), QuerySortByField.descByName(""), setOf(testCid)) private val mutableState: QueryChannelsMutableState = mock { on(it.rawChannels) doReturn emptyMap() @@ -140,7 +136,7 @@ internal class QueryChannelsStateLogicTest { queryChannelsStateLogic.addChannelsState(channels) - queryChannelsSpec.cids `should contain same` setOf(testCid, channel1.cid, channel2.cid) + verify(mutableState).setCids(setOf(testCid, channel1.cid, channel2.cid)) verify(mutableState).setChannels(channels.associateBy { it.cid }) } @@ -211,8 +207,11 @@ internal class QueryChannelsStateLogicTest { val chB = randomChannel(type = "messaging", id = "b") val chC = randomChannel(type = "messaging", id = "c") val channels = mapOf(chA.cid to chA, chB.cid to chB, chC.cid to chC) - val spec = QueryChannelsSpec(Filters.neutral(), QuerySortByField.descByName("")) - .apply { cids = setOf(chA.cid, chB.cid, chC.cid) } + val spec = QueryChannelsSpec( + filter = Filters.neutral(), + querySort = QuerySortByField.descByName(""), + cids = setOf(chA.cid, chB.cid, chC.cid), + ) whenever(mutableState.rawChannels) doReturn channels whenever(mutableState.queryChannelsSpec) doReturn spec @@ -220,7 +219,7 @@ internal class QueryChannelsStateLogicTest { val logic = QueryChannelsStateLogic(mutableState, stateRegistry, logicRegistry, testCoroutines.scope) logic.removeChannels(setOf(chA.cid, chC.cid)) - assertEquals(setOf(chB.cid), spec.cids) + verify(mutableState).setCids(setOf(chB.cid)) verify(mutableState).setChannels(mapOf(chB.cid to chB)) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt index 2609e8e2ff3..921a0124e07 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt @@ -17,13 +17,10 @@ package io.getstream.chat.android.state.plugin.state.internal import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.setup.state.ClientState -import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.User -import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.plugin.internal.StatePlugin import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry @@ -63,9 +60,7 @@ internal class ChatClientStateCallsTest { private lateinit var chatClientStateCalls: ChatClientStateCalls private val userFlow = MutableStateFlow(null) - private val filter = Filters.eq("type", "messaging") - private val sort = QuerySortByField.descByName("last_message_at") - private val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + private val identifier = QueryChannelsIdentifier.Grouped("test-group") @BeforeEach fun setUp() { @@ -75,10 +70,11 @@ internal class ChatClientStateCallsTest { queryChannelsState = mock() stateRegistry = mock { on(it.queryChannels(any(), any())) doReturn queryChannelsState + on(it.queryChannels(any())) doReturn queryChannelsState } queryChannelsLogic = mock() logicRegistry = mock { - on(it.queryChannels(any())) doReturn queryChannelsLogic + on(it.queryChannels(any())) doReturn queryChannelsLogic } val statePlugin: StatePlugin = mock { @@ -102,9 +98,9 @@ internal class ChatClientStateCallsTest { val factory = ChatEventHandlerFactory(clientState) // When - val result = chatClientStateCalls.initQueryChannelsState(request, factory) + val result = chatClientStateCalls.initQueryChannelsState(identifier, factory) - // Then — no API call, no offline load (offline read happens later in prefillChannels) + // Then — no remote queryChannels API call; the offline grouped load runs locally. verify(chatClient, never()).queryChannels(any()) assertNotNull(result) } @@ -117,7 +113,7 @@ internal class ChatClientStateCallsTest { // When - launch initQueryChannelsState (it should suspend waiting for user) val job = launch { - chatClientStateCalls.initQueryChannelsState(request, factory) + chatClientStateCalls.initQueryChannelsState(identifier, factory) completed = true } advanceUntilIdle() @@ -135,15 +131,15 @@ internal class ChatClientStateCallsTest { } @Test - fun `initQueryChannelsState returns state matching request filter and sort`() = runTest { + fun `initQueryChannelsState returns state matching the identifier`() = runTest { // Given userFlow.value = User(id = "test-user") val factory = ChatEventHandlerFactory(clientState) // When - chatClientStateCalls.initQueryChannelsState(request, factory) + chatClientStateCalls.initQueryChannelsState(identifier, factory) - // Then - stateRegistry.queryChannels should be called with the request's filter and sort - verify(stateRegistry).queryChannels(filter, sort) + // Then - stateRegistry.queryChannels should be called with the identifier + verify(stateRegistry).queryChannels(identifier) } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt index 0df21b37520..795f7ddfaf1 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt @@ -47,6 +47,8 @@ internal class QueryChannelsMutableStateTest { private val filter = Filters.eq("type", "messaging") private val sort = QuerySortByField.descByName("last_message_at") + private val identifier = io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier + .Standard(filter, sort) private val latestUsers = MutableStateFlow>(emptyMap()) private val activeLiveLocations = MutableStateFlow>(emptyList()) @@ -55,8 +57,7 @@ internal class QueryChannelsMutableStateTest { @BeforeEach fun setUp() { state = QueryChannelsMutableState( - filter = filter, - sort = sort, + identifier = identifier, scope = testCoroutines.scope, latestUsers = latestUsers, activeLiveLocations = activeLiveLocations, From 5a9329596a7ae40f0053820c29b774febf771116 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 14 May 2026 12:00:05 +0200 Subject: [PATCH 16/21] Add group-based channel distribution. --- .../channels/ChannelListViewModel.kt | 11 +- .../channels/ChannelViewModelFactory.kt | 6 +- .../api/stream-chat-android-state.api | 31 +++ .../handler/chat/ChannelGroupResolver.kt | 40 +++ .../chat/DefaultChannelGroupResolver.kt | 46 ++++ .../chat/GroupAwareChatEventHandler.kt | 135 ++++++++++ .../GroupAwareChatEventHandlerFactory.kt | 55 ++++ .../chat/DefaultChannelGroupResolverTest.kt | 75 ++++++ .../chat/GroupAwareChatEventHandlerTest.kt | 255 ++++++++++++++++++ 9 files changed, 652 insertions(+), 2 deletions(-) create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 269795e8dc5..36acc220fe1 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -46,6 +46,7 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.event.handler.chat.factory.GroupAwareChatEventHandlerFactory import io.getstream.chat.android.state.extensions.globalStateFlow import io.getstream.chat.android.state.extensions.initQueryChannelsAsState import io.getstream.chat.android.state.extensions.queryChannelsAsState @@ -144,6 +145,11 @@ public class ChannelListViewModel internal constructor( /** * Grouped channel list constructor. Subscribes to the state identified by [groupKey] without * issuing a remote call; the state is populated externally by `queryGroupedChannels` responses. + * + * Defaults [chatEventHandlerFactory] to a [GroupAwareChatEventHandlerFactory] keyed on + * [groupKey] so that `channel.updated` and channel-add events route channels into the + * correct group out of the box. Pass a custom factory (e.g. with a custom + * [io.getstream.chat.android.state.event.handler.chat.ChannelGroupResolver]) to override. */ public constructor( chatClient: ChatClient, @@ -151,7 +157,10 @@ public class ChannelListViewModel internal constructor( channelLimit: Int = DEFAULT_CHANNEL_LIMIT, memberLimit: Int? = null, messageLimit: Int? = null, - chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + chatEventHandlerFactory: ChatEventHandlerFactory = GroupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = chatClient.clientState, + ), searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, isDraftMessageEnabled: Boolean = false, messageSearchSort: QuerySorter? = null, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index 9c49d0f6c9b..f8c63c2b712 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -27,6 +27,7 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.event.handler.chat.factory.GroupAwareChatEventHandlerFactory import io.getstream.chat.android.state.extensions.globalStateFlow /** @@ -110,7 +111,10 @@ public class ChannelViewModelFactory internal constructor( channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, memberLimit: Int? = null, messageLimit: Int? = null, - chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + chatEventHandlerFactory: ChatEventHandlerFactory = GroupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = chatClient.clientState, + ), isDraftMessageEnabled: Boolean = false, messageSearchSort: QuerySorter? = null, ) : this( diff --git a/stream-chat-android-state/api/stream-chat-android-state.api b/stream-chat-android-state/api/stream-chat-android-state.api index 6ae393877ed..33dbcee7fd3 100644 --- a/stream-chat-android-state/api/stream-chat-android-state.api +++ b/stream-chat-android-state/api/stream-chat-android-state.api @@ -5,10 +5,27 @@ public abstract class io/getstream/chat/android/state/event/handler/chat/BaseCha public fun handleCidEvent (Lio/getstream/chat/android/client/events/CidEvent;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; } +public abstract interface class io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver { + public abstract fun resolve (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Ljava/util/Set; +} + public abstract interface class io/getstream/chat/android/state/event/handler/chat/ChatEventHandler { public abstract fun handleChatEvent (Lio/getstream/chat/android/client/events/ChatEvent;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; } +public final class io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver : io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver { + public static final field Companion Lio/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver$Companion; + public static final field DEFAULT_ALL_GROUP_KEY Ljava/lang/String; + public static final field DEFAULT_GROUP_FIELD_NAME Ljava/lang/String; + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun resolve (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Ljava/util/Set; +} + +public final class io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver$Companion { +} + public class io/getstream/chat/android/state/event/handler/chat/DefaultChatEventHandler : io/getstream/chat/android/state/event/handler/chat/BaseChatEventHandler { public fun (Lkotlinx/coroutines/flow/StateFlow;Lio/getstream/chat/android/client/setup/state/ClientState;)V protected final fun addIfChannelIsAbsent (Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; @@ -61,6 +78,14 @@ public final class io/getstream/chat/android/state/event/handler/chat/EventHandl public fun toString ()Ljava/lang/String; } +public class io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler : io/getstream/chat/android/state/event/handler/chat/DefaultChatEventHandler { + public fun (Ljava/lang/String;Lio/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver;Lkotlinx/coroutines/flow/StateFlow;Lio/getstream/chat/android/client/setup/state/ClientState;)V + protected final fun getGroupKey ()Ljava/lang/String; + protected final fun getResolver ()Lio/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver; + public fun handleChannelEvent (Lio/getstream/chat/android/client/events/HasChannel;Lio/getstream/chat/android/models/FilterObject;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; + public fun handleCidEvent (Lio/getstream/chat/android/client/events/CidEvent;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; +} + public class io/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory { public fun ()V public fun (Lio/getstream/chat/android/client/setup/state/ClientState;)V @@ -68,6 +93,12 @@ public class io/getstream/chat/android/state/event/handler/chat/factory/ChatEven public fun chatEventHandler (Lkotlinx/coroutines/flow/StateFlow;)Lio/getstream/chat/android/state/event/handler/chat/ChatEventHandler; } +public class io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory : io/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory { + public fun (Ljava/lang/String;Lio/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver;Lio/getstream/chat/android/client/setup/state/ClientState;)V + public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver;Lio/getstream/chat/android/client/setup/state/ClientState;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun chatEventHandler (Lkotlinx/coroutines/flow/StateFlow;)Lio/getstream/chat/android/state/event/handler/chat/ChatEventHandler; +} + public final class io/getstream/chat/android/state/extensions/ChatClientExtensions { public static final fun cancelEphemeralMessage (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/Message;)Lio/getstream/result/call/Call; public static final fun downloadAttachment (Lio/getstream/chat/android/client/ChatClient;Landroid/content/Context;Lio/getstream/chat/android/models/Attachment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lio/getstream/result/call/Call; diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt new file mode 100644 index 00000000000..25ec3eb66a4 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt @@ -0,0 +1,40 @@ +/* + * 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.state.event.handler.chat + +import io.getstream.chat.android.models.Channel + +/** + * Resolves the set of group keys a [Channel] belongs to for the purposes of grouped channel + * lists driven by `queryGroupedChannels`. + * + * Used by [GroupAwareChatEventHandler] to decide whether an incoming event-bearing channel + * should be added to, removed from, or skipped by a query identified by + * [io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier.Grouped]. + */ +public fun interface ChannelGroupResolver { + + /** + * @param channel The channel whose group membership is being resolved. + * @param currentGroup The group key of the query asking. Most resolvers will not need this, + * but it allows a single resolver instance to be shared across multiple grouped queries and + * still differentiate behavior per asker (e.g. logging, short-circuiting, per-group rules). + * @return The set of group keys this channel belongs to. A channel can belong to multiple + * groups (e.g. an explicit group plus an `"all"` sentinel). + */ + public fun resolve(channel: Channel, currentGroup: String): Set +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt new file mode 100644 index 00000000000..284136c52c0 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt @@ -0,0 +1,46 @@ +/* + * 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.state.event.handler.chat + +import io.getstream.chat.android.models.Channel + +/** + * Default [ChannelGroupResolver] backed by `channel.extraData`. + * + * Reads an explicit group key from `channel.extraData[groupFieldName]` (defaults to `"group"`) + * and always includes the [allGroupKey] sentinel (defaults to `"all"`) so that a designated + * "all channels" grouped query always sees every channel. + * + * @param groupFieldName The key in `channel.extraData` carrying the explicit group identifier. + * @param allGroupKey The sentinel group key representing "every channel". Pass `null` to disable + * the implicit sentinel. + */ +public class DefaultChannelGroupResolver( + private val groupFieldName: String = DEFAULT_GROUP_FIELD_NAME, + private val allGroupKey: String? = DEFAULT_ALL_GROUP_KEY, +) : ChannelGroupResolver { + + override fun resolve(channel: Channel, currentGroup: String): Set = buildSet { + (channel.extraData[groupFieldName] as? String)?.let(::add) + allGroupKey?.let(::add) + } + + public companion object { + public const val DEFAULT_GROUP_FIELD_NAME: String = "group" + public const val DEFAULT_ALL_GROUP_KEY: String = "all" + } +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt new file mode 100644 index 00000000000..3b91da77b16 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt @@ -0,0 +1,135 @@ +/* + * 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.state.event.handler.chat + +import io.getstream.chat.android.client.events.ChannelUpdatedByUserEvent +import io.getstream.chat.android.client.events.ChannelUpdatedEvent +import io.getstream.chat.android.client.events.ChannelVisibleEvent +import io.getstream.chat.android.client.events.CidEvent +import io.getstream.chat.android.client.events.HasChannel +import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent +import io.getstream.chat.android.client.events.NotificationMessageNewEvent +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import kotlinx.coroutines.flow.StateFlow + +/** + * [ChatEventHandler] that routes channels in and out of a grouped channel list based on the + * channel's resolved group(s). + * + * Intended to be paired with `QueryChannelsIdentifier.Grouped(groupKey)` — one handler instance + * per grouped query. On every event carrying full channel data (e.g. [ChannelUpdatedEvent]), + * the handler asks the [resolver] which groups the channel belongs to: + * - If [groupKey] is in the set and the channel is not currently in this list, [EventHandlingResult.Add]. + * - If [groupKey] is not in the set and the channel IS currently in this list, [EventHandlingResult.Remove]. + * - Otherwise [EventHandlingResult.Skip] (no state churn for re-adding already-present channels + * nor for ignoring non-members). + * + * For events that carry only a `cid` (e.g. [io.getstream.chat.android.client.events.MemberAddedEvent]), + * the handler delegates to [DefaultChatEventHandler] and then filters any resulting `Add` through + * the resolver, using the supplied `cachedChannel` as the input to the group lookup. + * + * Removal events (`ChannelDeletedEvent`, `ChannelHiddenEvent`, `MemberRemovedEvent` for the + * current user, etc.) are inherited from [DefaultChatEventHandler] unchanged — leaving a channel + * removes it from any list it was in, regardless of group. + * + * @param groupKey The group identifier this handler is responsible for. + * @param resolver Decides which group keys a channel belongs to. + * @param channels Visible-channel map for this grouped query (used to gate Remove decisions). + * @param clientState Used for membership checks inherited from [DefaultChatEventHandler]. + */ +public open class GroupAwareChatEventHandler( + protected val groupKey: String, + protected val resolver: ChannelGroupResolver, + channels: StateFlow?>, + clientState: ClientState, +) : DefaultChatEventHandler(channels, clientState) { + + override fun handleChannelEvent(event: HasChannel, filter: FilterObject): EventHandlingResult { + return when (event) { + // ChannelUpdated[ByUser]Event: re-route by the channel's current group. + is ChannelUpdatedEvent, + is ChannelUpdatedByUserEvent, + -> routeByGroup(event.channel) + + // Channel-bearing add events: only watch+add if the channel belongs in this group. + // Mirrors the default's WatchAndAdd choice but gated by the resolver against the + // event's channel snapshot. + is NotificationAddedToChannelEvent, + is NotificationMessageNewEvent, + is ChannelVisibleEvent, + -> if (channelBelongsHere(event.channel)) { + EventHandlingResult.WatchAndAdd(event.cid) + } else { + EventHandlingResult.Skip + } + + // Removes/visibility-loss and everything else: inherit default behavior. + else -> super.handleChannelEvent(event, filter) + } + } + + override fun handleCidEvent( + event: CidEvent, + filter: FilterObject, + cachedChannel: Channel?, + ): EventHandlingResult { + val defaultResult = super.handleCidEvent(event, filter, cachedChannel) + return filterResultByGroup(defaultResult, cachedChannel) + } + + /** + * Routes a channel-bearing event to Add / Remove / Skip based on the channel's resolved groups + * and whether it is currently in this grouped list. Re-adding an already-present channel is + * skipped — channel-state updates flow through a separate pipeline, so we don't churn the list. + */ + private fun routeByGroup(channel: Channel): EventHandlingResult { + val belongsHere = channelBelongsHere(channel) + val isInList = channels.value?.containsKey(channel.cid) == true + return when { + belongsHere && !isInList -> EventHandlingResult.Add(channel) + !belongsHere && isInList -> EventHandlingResult.Remove(channel.cid) + else -> EventHandlingResult.Skip + } + } + + private fun channelBelongsHere(channel: Channel): Boolean = + resolver.resolve(channel, groupKey).contains(groupKey) + + /** + * Downgrades an `Add`/`WatchAndAdd` from the default handler to `Skip` if the resolver says + * the channel does not belong in this group. `Remove`/`Skip` pass through unchanged. + */ + private fun filterResultByGroup( + result: EventHandlingResult, + cachedChannel: Channel?, + ): EventHandlingResult = when (result) { + is EventHandlingResult.Add -> + if (channelBelongsHere(result.channel)) result else EventHandlingResult.Skip + is EventHandlingResult.WatchAndAdd -> + // No channel data on the event; use cachedChannel if available. If we have nothing + // to resolve against, trust the default and rely on the subsequent channel.updated + // (which carries full channel data) to clean up. + if (cachedChannel != null && !channelBelongsHere(cachedChannel)) { + EventHandlingResult.Skip + } else { + result + } + else -> result + } +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt new file mode 100644 index 00000000000..02323c087d9 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt @@ -0,0 +1,55 @@ +/* + * 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.state.event.handler.chat.factory + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.state.event.handler.chat.ChannelGroupResolver +import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler +import io.getstream.chat.android.state.event.handler.chat.DefaultChannelGroupResolver +import io.getstream.chat.android.state.event.handler.chat.GroupAwareChatEventHandler +import kotlinx.coroutines.flow.StateFlow + +/** + * Produces [GroupAwareChatEventHandler] instances for grouped channel lists. + * + * Pair with `QueryChannelsIdentifier.Grouped(groupKey)` when initializing a grouped query's + * state via `ChannelListViewModel(chatClient, groupKey = ...)` so that `channel.updated` and + * channel-add events route channels into the correct group. + * + * @param groupKey The group identifier this factory is producing handlers for. + * @param resolver Decides which group keys a channel belongs to. Defaults to + * [DefaultChannelGroupResolver], which reads `channel.extraData["group"]` and always includes + * an implicit `"all"` sentinel. + * @param clientState Used by the inherited [io.getstream.chat.android.state.event.handler.chat.DefaultChatEventHandler] + * to perform current-user membership checks. + */ +public open class GroupAwareChatEventHandlerFactory( + private val groupKey: String, + private val resolver: ChannelGroupResolver = DefaultChannelGroupResolver(), + private val clientState: ClientState = ChatClient.instance().clientState, +) : ChatEventHandlerFactory(clientState) { + + override fun chatEventHandler(channels: StateFlow?>): ChatEventHandler = + GroupAwareChatEventHandler( + groupKey = groupKey, + resolver = resolver, + channels = channels, + clientState = clientState, + ) +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt new file mode 100644 index 00000000000..3345e5a6e7d --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt @@ -0,0 +1,75 @@ +/* + * 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.state.event.handler.chat + +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomString +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class DefaultChannelGroupResolverTest { + + @Test + fun `Given a channel with an explicit group When resolved Then returns the group plus the all sentinel`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channel, currentGroup = randomString()) + + assertEquals(setOf("vip", "all"), result) + } + + @Test + fun `Given a channel with no group extra When resolved Then returns only the all sentinel`() { + val channel = randomChannel(extraData = emptyMap()) + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channel, currentGroup = randomString()) + + assertEquals(setOf("all"), result) + } + + @Test + fun `Given a custom group field name When resolved Then reads that field`() { + val channel = randomChannel(extraData = mapOf("tier" to "gold", "group" to "ignored")) + val resolver = DefaultChannelGroupResolver(groupFieldName = "tier") + + val result = resolver.resolve(channel, currentGroup = randomString()) + + assertEquals(setOf("gold", "all"), result) + } + + @Test + fun `Given the all sentinel is disabled When resolved Then returns only the explicit group`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val resolver = DefaultChannelGroupResolver(allGroupKey = null) + + val result = resolver.resolve(channel, currentGroup = randomString()) + + assertEquals(setOf("vip"), result) + } + + @Test + fun `Given a non-string group extra When resolved Then ignores it and returns only the all sentinel`() { + val channel = randomChannel(extraData = mapOf("group" to 42)) + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channel, currentGroup = randomString()) + + assertEquals(setOf("all"), result) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt new file mode 100644 index 00000000000..a02200448c9 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt @@ -0,0 +1,255 @@ +/* + * 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.state.event.handler.chat + +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.client.test.randomChannelDeletedEvent +import io.getstream.chat.android.client.test.randomChannelUpdatedEvent +import io.getstream.chat.android.client.test.randomMemberAddedEvent +import io.getstream.chat.android.client.test.randomMemberRemovedEvent +import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent +import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomUser +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class GroupAwareChatEventHandlerTest { + + private val defaultResolver = DefaultChannelGroupResolver() + + @Test + fun `Given channel belongs to this group and is not cached When ChannelUpdatedEvent arrives Should add`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given channel belongs to this group and is already cached When ChannelUpdatedEvent arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given channel moved to another group and is currently cached When ChannelUpdatedEvent arrives Should remove`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given channel belongs to another group and is not cached When ChannelUpdatedEvent arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given handler is for the all group When ChannelUpdatedEvent arrives Should always add`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap()) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given channel does not belong here When NotificationAddedToChannelEvent arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given channel belongs here When NotificationAddedToChannelEvent arrives Should watch and add`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given channel does not belong here When NotificationMessageNewEvent arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationMessageNewEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given current user joined with matching cached channel When MemberAddedEvent arrives Should add`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomMemberAddedEvent( + cid = channel.cid, + member = randomMember(user = currentUser), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given current user joined with non-matching cached channel When MemberAddedEvent arrives Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomMemberAddedEvent( + cid = channel.cid, + member = randomMember(user = currentUser), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given current user left a cached channel When MemberRemovedEvent arrives Should remove regardless of group`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = randomMemberRemovedEvent( + cid = channel.cid, + member = randomMember(user = currentUser), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a cached channel When ChannelDeletedEvent arrives Should remove regardless of group`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + ) + val event = randomChannelDeletedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a custom resolver that reads a different field When ChannelUpdatedEvent arrives Should use custom field`() { + val channel = randomChannel(extraData = mapOf("tier" to "vip")) + val customResolver = ChannelGroupResolver { ch, _ -> + setOfNotNull(ch.extraData["tier"] as? String) + } + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + resolver = customResolver, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given custom resolver Should receive the handler's groupKey as currentGroup`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + var capturedGroup: String? = null + val capturingResolver = ChannelGroupResolver { _, currentGroup -> + capturedGroup = currentGroup + setOf("vip") + } + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + resolver = capturingResolver, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals("vip", capturedGroup) + } + + private fun handlerFor( + groupKey: String, + cachedChannels: Map, + resolver: ChannelGroupResolver = defaultResolver, + currentUser: io.getstream.chat.android.models.User? = null, + ): GroupAwareChatEventHandler { + val clientState = mock { + whenever(it.user) doReturn MutableStateFlow(currentUser) + } + return GroupAwareChatEventHandler( + groupKey = groupKey, + resolver = resolver, + channels = MutableStateFlow(cachedChannels), + clientState = clientState, + ) + } +} From 78c3a62bb75c37ac968e243364c902a1e5ee71f8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 14 May 2026 15:39:03 +0200 Subject: [PATCH 17/21] Add channel_custom based distribution. --- .../chat/android/client/test/Mother.kt | 12 ++ .../api/stream-chat-android-client.api | 57 ++++--- .../client/api2/mapping/EventMapping.kt | 10 +- ...ChannelDtos.kt => DownstreamChannelDto.kt} | 15 -- .../client/api2/model/dto/EventDtos.kt | 7 +- .../chat/android/client/events/ChatEvent.kt | 6 + .../api2/mapping/EventMappingTestArguments.kt | 26 ++- .../api/stream-chat-android-compose.api | 11 +- .../channels/ChannelListViewModel.kt | 25 ++- .../channels/ChannelViewModelFactory.kt | 16 +- .../channels/ChannelListViewModelTest.kt | 1 - .../api/stream-chat-android-state.api | 31 ---- .../handler/chat/ChannelGroupResolver.kt | 16 +- .../chat/DefaultChannelGroupResolver.kt | 21 ++- .../chat/GroupAwareChatEventHandler.kt | 114 ++++++------- .../GroupAwareChatEventHandlerFactory.kt | 32 ++-- .../android/state/extensions/ChatClient.kt | 13 +- .../state/internal/ChatClientStateCalls.kt | 4 +- .../chat/DefaultChannelGroupResolverTest.kt | 35 ++-- .../chat/GroupAwareChatEventHandlerTest.kt | 156 ++++++++++++++---- .../internal/ChatClientStateCallsTest.kt | 14 +- 21 files changed, 359 insertions(+), 263 deletions(-) rename stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/{ChannelDtos.kt => DownstreamChannelDto.kt} (77%) diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index a0df403c0df..55817083494 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -133,6 +133,7 @@ public fun randomChannelVisibleEvent( channelId: String = randomString(), user: User = randomUser(), channel: Channel = randomChannel(), + channelCustom: Map? = null, ): ChannelVisibleEvent = ChannelVisibleEvent( type = EventType.CHANNEL_VISIBLE, createdAt = createdAt, @@ -142,6 +143,7 @@ public fun randomChannelVisibleEvent( channelId = channelId, user = user, channel = channel, + channelCustom = channelCustom, ) public fun randomUserStartWatchingEvent( @@ -445,6 +447,7 @@ public fun randomNotificationAddedToChannelEvent( member: Member = randomMember(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + channelCustom: Map? = null, ): NotificationAddedToChannelEvent { return NotificationAddedToChannelEvent( type = EventType.NOTIFICATION_ADDED_TO_CHANNEL, @@ -457,6 +460,7 @@ public fun randomNotificationAddedToChannelEvent( member = member, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + channelCustom = channelCustom, ) } @@ -470,6 +474,7 @@ public fun randomNotificationMessageNewEvent( totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), groupedUnreadChannels: Map? = null, + channelCustom: Map? = null, ): NotificationMessageNewEvent = NotificationMessageNewEvent( type = EventType.NOTIFICATION_MESSAGE_NEW, createdAt = createdAt, @@ -482,6 +487,7 @@ public fun randomNotificationMessageNewEvent( totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, groupedUnreadChannels = groupedUnreadChannels, + channelCustom = channelCustom, ) public fun randomMessageUpdateEvent( @@ -509,6 +515,7 @@ public fun randomChannelUpdatedEvent( channelId: String = randomString(), message: Message = randomMessage(), channel: Channel = randomChannel(), + channelCustom: Map? = null, ): ChannelUpdatedEvent { return ChannelUpdatedEvent( type = EventType.CHANNEL_UPDATED, @@ -519,6 +526,7 @@ public fun randomChannelUpdatedEvent( channelId = channelId, message = message, channel = channel, + channelCustom = channelCustom, ) } @@ -530,6 +538,7 @@ public fun randomChannelUpdatedByUserEvent( message: Message = randomMessage(), channel: Channel = randomChannel(), user: User = randomUser(), + channelCustom: Map? = null, ): ChannelUpdatedByUserEvent { return ChannelUpdatedByUserEvent( type = EventType.CHANNEL_UPDATED, @@ -541,6 +550,7 @@ public fun randomChannelUpdatedByUserEvent( message = message, channel = channel, user = user, + channelCustom = channelCustom, ) } @@ -556,6 +566,7 @@ public fun randomNewMessageEvent( unreadChannels: Int = randomInt(), channelMessageCount: Int? = positiveRandomInt(), groupedUnreadChannels: Map? = null, + channelCustom: Map? = null, ): NewMessageEvent { return NewMessageEvent( type = EventType.MESSAGE_NEW, @@ -571,6 +582,7 @@ public fun randomNewMessageEvent( unreadChannels = unreadChannels, channelMessageCount = channelMessageCount, groupedUnreadChannels = groupedUnreadChannels, + channelCustom = channelCustom, ) } diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 2eb8f485315..db90131b3a1 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -1213,8 +1213,10 @@ public final class io/getstream/chat/android/client/events/ChannelTruncatedEvent } public final class io/getstream/chat/android/client/events/ChannelUpdatedByUserEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1223,10 +1225,11 @@ public final class io/getstream/chat/android/client/events/ChannelUpdatedByUserE public final fun component7 ()Lio/getstream/chat/android/models/User; public final fun component8 ()Lio/getstream/chat/android/models/Channel; public final fun component9 ()Lio/getstream/chat/android/models/Message; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; @@ -1240,7 +1243,8 @@ public final class io/getstream/chat/android/client/events/ChannelUpdatedByUserE } public final class io/getstream/chat/android/client/events/ChannelUpdatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; @@ -1249,10 +1253,12 @@ public final class io/getstream/chat/android/client/events/ChannelUpdatedEvent : public final fun component6 ()Ljava/lang/String; public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/Message; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Lio/getstream/chat/android/client/events/ChannelUpdatedEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelUpdatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelUpdatedEvent; + public final fun component9 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)Lio/getstream/chat/android/client/events/ChannelUpdatedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelUpdatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelUpdatedEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; @@ -1315,7 +1321,8 @@ public final class io/getstream/chat/android/client/events/ChannelUserUnbannedEv } public final class io/getstream/chat/android/client/events/ChannelVisibleEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; @@ -1324,10 +1331,12 @@ public final class io/getstream/chat/android/client/events/ChannelVisibleEvent : public final fun component6 ()Ljava/lang/String; public final fun component7 ()Lio/getstream/chat/android/models/User; public final fun component8 ()Lio/getstream/chat/android/models/Channel; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/client/events/ChannelVisibleEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelVisibleEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelVisibleEvent; + public final fun component9 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Ljava/util/Map;)Lio/getstream/chat/android/client/events/ChannelVisibleEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelVisibleEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelVisibleEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; @@ -1783,13 +1792,14 @@ public final class io/getstream/chat/android/client/events/MessageUpdatedEvent : } public final class io/getstream/chat/android/client/events/NewMessageEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasWatcherCount, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()I public final fun component12 ()Ljava/lang/Integer; public final fun component13 ()Ljava/util/Map; + public final fun component14 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -1798,9 +1808,10 @@ public final class io/getstream/chat/android/client/events/NewMessageEvent : io/ public final fun component7 ()Ljava/lang/String; public final fun component8 ()Lio/getstream/chat/android/models/Message; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NewMessageEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NewMessageEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NewMessageEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NewMessageEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NewMessageEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NewMessageEvent; public fun equals (Ljava/lang/Object;)Z + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public final fun getChannelMessageCount ()Ljava/lang/Integer; public fun getChannelType ()Ljava/lang/String; @@ -1819,10 +1830,11 @@ public final class io/getstream/chat/android/client/events/NewMessageEvent : io/ } public final class io/getstream/chat/android/client/events/NotificationAddedToChannelEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasMember, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I + public final fun component11 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1831,10 +1843,11 @@ public final class io/getstream/chat/android/client/events/NotificationAddedToCh public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/Member; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;II)Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; @@ -2089,11 +2102,12 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea } public final class io/getstream/chat/android/client/events/NotificationMessageNewEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()Ljava/util/Map; + public final fun component12 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -2102,10 +2116,11 @@ public final class io/getstream/chat/android/client/events/NotificationMessageNe public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/Message; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMessageNewEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMessageNewEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index 3b7e32593d5..07b80e4a2ef 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -301,6 +301,7 @@ internal class EventMapping( channelId = channel_id, message = message?.toDomain(channel.toChannelInfo()), channel = channel.toDomain(), + channelCustom = channel_custom, ) } @@ -318,6 +319,7 @@ internal class EventMapping( user = user.toDomain(), message = message?.toDomain(channel.toChannelInfo()), channel = channel.toDomain(), + channelCustom = channel_custom, ) } @@ -334,6 +336,7 @@ internal class EventMapping( channelId = channel_id, user = user.toDomain(), channel = channel.toDomain(), + channelCustom = channel_custom, ) } @@ -471,8 +474,8 @@ internal class EventMapping( id = channel_id, type = channel_type, memberCount = channel_member_count ?: 0, - name = channel_custom?.name, - image = channel_custom?.image, + name = channel_custom?.get("name") as? String, + image = channel_custom?.get("image") as? String, ) NewMessageEvent( type = type, @@ -488,6 +491,7 @@ internal class EventMapping( unreadChannels = unread_channels, channelMessageCount = channel_message_count, groupedUnreadChannels = grouped_unread_channels, + channelCustom = channel_custom, ) } @@ -506,6 +510,7 @@ internal class EventMapping( member = member.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + channelCustom = channel_custom, ) } @@ -686,6 +691,7 @@ internal class EventMapping( totalUnreadCount = total_unread_count, unreadChannels = unread_channels, groupedUnreadChannels = grouped_unread_channels, + channelCustom = channel_custom, ) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/DownstreamChannelDto.kt similarity index 77% rename from stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt rename to stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/DownstreamChannelDto.kt index 5a54ee5c804..2cfea86955a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/DownstreamChannelDto.kt @@ -49,18 +49,3 @@ internal data class DownstreamChannelDto( val message_count: Int? = null, val extraData: Map, ) : ExtraDataDto - -/** - * Model holding custom channel fields delivered with `message.new` events. - * - * Note: It is currently relevant only for the [name] and [image] fields. If in the future we need to support more or - * even custom fields, consider changing this DTO (to a Map for example). - * - * @param name The channel name (if available). - * @param image The channel image (if available). - */ -@JsonClass(generateAdapter = true) -internal data class DownstreamChannelCustomDto( - val name: String?, - val image: String?, -) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index fef93966a69..4bb9842cf54 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -67,6 +67,7 @@ internal data class ChannelUpdatedEventDto( val channel_id: String, val message: DownstreamMessageDto?, val channel: DownstreamChannelDto, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -79,6 +80,7 @@ internal data class ChannelUpdatedByUserEventDto( val user: DownstreamUserDto, val message: DownstreamMessageDto?, val channel: DownstreamChannelDto, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -90,6 +92,7 @@ internal data class ChannelVisibleEventDto( val channel_id: String, val user: DownstreamUserDto, val channel: DownstreamChannelDto, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -189,7 +192,7 @@ internal data class NewMessageEventDto( val user: DownstreamUserDto, val cid: String, val channel_member_count: Int?, - val channel_custom: DownstreamChannelCustomDto?, + val channel_custom: Map?, val channel_type: String, val channel_id: String, val message: DownstreamMessageDto, @@ -225,6 +228,7 @@ internal data class NotificationAddedToChannelEventDto( val member: DownstreamMemberDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -353,6 +357,7 @@ internal data class NotificationMessageNewEventDto( val total_unread_count: Int = 0, val unread_channels: Int = 0, val grouped_unread_channels: Map? = null, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index 6e0adef0732..53db101dd36 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -197,6 +197,7 @@ public data class ChannelUpdatedEvent( override val channelId: String, override val channel: Channel, val message: Message?, + val channelCustom: Map? = null, ) : CidEvent(), HasChannel /** @@ -212,6 +213,7 @@ public data class ChannelUpdatedByUserEvent( override val user: User, override val channel: Channel, val message: Message?, + val channelCustom: Map? = null, ) : CidEvent(), UserEvent, HasChannel /** @@ -226,6 +228,7 @@ public data class ChannelVisibleEvent( override val channelId: String, override val user: User, override val channel: Channel, + val channelCustom: Map? = null, ) : CidEvent(), UserEvent, HasChannel /** @@ -373,6 +376,7 @@ public data class NewMessageEvent( override val unreadChannels: Int = 0, val channelMessageCount: Int?, override val groupedUnreadChannels: Map? = null, + val channelCustom: Map? = null, ) : CidEvent(), UserEvent, HasMessage, HasWatcherCount, HasUnreadCounts, HasGroupedUnreadChannels /** @@ -389,6 +393,7 @@ public data class NotificationAddedToChannelEvent( override val member: Member, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, + val channelCustom: Map? = null, ) : CidEvent(), HasChannel, HasMember, HasUnreadCounts /** @@ -548,6 +553,7 @@ public data class NotificationMessageNewEvent( override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, override val groupedUnreadChannels: Map? = null, + val channelCustom: Map? = null, ) : CidEvent(), HasChannel, HasMessage, HasUnreadCounts, HasGroupedUnreadChannels /** diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index 88738e86331..45bdee02959 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -33,7 +33,6 @@ import io.getstream.chat.android.client.api2.model.dto.ConnectedEventDto import io.getstream.chat.android.client.api2.model.dto.ConnectingEventDto import io.getstream.chat.android.client.api2.model.dto.ConnectionErrorEventDto import io.getstream.chat.android.client.api2.model.dto.DisconnectedEventDto -import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelCustomDto import io.getstream.chat.android.client.api2.model.dto.DraftMessageDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.DraftMessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.ErrorEventDto @@ -186,6 +185,11 @@ internal object EventMappingTestArguments { private val CHANNEL_MEMBER_COUNT = positiveRandomInt() private val CHANNEL_NAME = randomString() private val CHANNEL_IMAGE = randomString() + private val CHANNEL_CUSTOM: Map = mapOf( + "name" to CHANNEL_NAME, + "image" to CHANNEL_IMAGE, + "group" to "vip", + ) private val MESSAGE_ID = randomString() private val MESSAGE = Mother.randomDownstreamMessageDto() private val MESSAGE_WITHOUT_CHANNEL_INFO = MESSAGE.copy(channel = null) @@ -226,10 +230,7 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, channel_member_count = CHANNEL_MEMBER_COUNT, - channel_custom = DownstreamChannelCustomDto( - name = CHANNEL_NAME, - image = CHANNEL_IMAGE, - ), + channel_custom = CHANNEL_CUSTOM, message = MESSAGE_WITHOUT_CHANNEL_INFO, grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) @@ -287,6 +288,7 @@ internal object EventMappingTestArguments { user = USER, message = MESSAGE, channel = CHANNEL, + channel_custom = CHANNEL_CUSTOM, ) private val channelUpdatedDto = ChannelUpdatedEventDto( @@ -297,6 +299,7 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, message = MESSAGE, channel = CHANNEL, + channel_custom = CHANNEL_CUSTOM, ) private val channelUserBannedDto = ChannelUserBannedEventDto( @@ -327,6 +330,7 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, channel = CHANNEL, user = USER, + channel_custom = CHANNEL_CUSTOM, ) private val connectedDto = ConnectedEventDto( @@ -464,6 +468,7 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, channel = CHANNEL, member = MEMBER, + channel_custom = CHANNEL_CUSTOM, ) private val notificationChannelDeletedDto = NotificationChannelDeletedEventDto( @@ -560,6 +565,7 @@ internal object EventMappingTestArguments { message = MESSAGE, channel = CHANNEL, grouped_unread_channels = GROUPED_UNREAD_CHANNELS, + channel_custom = CHANNEL_CUSTOM, ) private val notificationThreadMessageNewDto = NotificationThreadMessageNewEventDto( @@ -836,8 +842,8 @@ internal object EventMappingTestArguments { id = newMessageDto.channel_id, type = newMessageDto.channel_type, memberCount = newMessageDto.channel_member_count ?: 0, - name = newMessageDto.channel_custom?.name, - image = newMessageDto.channel_custom?.image, + name = newMessageDto.channel_custom?.get("name") as? String, + image = newMessageDto.channel_custom?.get("image") as? String, ) newMessageDto.message.toDomain(channelInfo) }, @@ -846,6 +852,7 @@ internal object EventMappingTestArguments { unreadChannels = newMessageDto.unread_channels, channelMessageCount = newMessageDto.channel_message_count, groupedUnreadChannels = newMessageDto.grouped_unread_channels, + channelCustom = newMessageDto.channel_custom, ) private val draftMessageUpdatedEvent = DraftMessageUpdatedEvent( @@ -911,6 +918,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { channelUpdatedByUserDto.channel.toDomain() }, + channelCustom = channelUpdatedByUserDto.channel_custom, ) private val channelUpdated = ChannelUpdatedEvent( @@ -924,6 +932,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { channelUpdatedDto.channel.toDomain() }, + channelCustom = channelUpdatedDto.channel_custom, ) private val channelUserBanned = ChannelUserBannedEvent( @@ -957,6 +966,7 @@ internal object EventMappingTestArguments { channelType = channelVisibleDto.channel_type, channel = with(domainMapping) { channelVisibleDto.channel.toDomain() }, channelId = channelVisibleDto.channel_id, + channelCustom = channelVisibleDto.channel_custom, ) private val connected = ConnectedEvent( @@ -1115,6 +1125,7 @@ internal object EventMappingTestArguments { notificationAddedToChannelDto.channel.toDomain() }, member = with(domainMapping) { notificationAddedToChannelDto.member.toDomain() }, + channelCustom = notificationAddedToChannelDto.channel_custom, ) private val notificationChannelDeleted = NotificationChannelDeletedEvent( @@ -1230,6 +1241,7 @@ internal object EventMappingTestArguments { notificationMessageNewDto.channel.toDomain() }, groupedUnreadChannels = notificationMessageNewDto.grouped_unread_channels, + channelCustom = notificationMessageNewDto.channel_custom, ) private val notificationThreadMessageNew = NotificationThreadMessageNewEvent( diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 15356e649b0..9d640557e71 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4896,8 +4896,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public static final field $stable I public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun deleteConversation (Lio/getstream/chat/android/models/Channel;)V public final fun dismissChannelAction ()V @@ -4943,10 +4943,9 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelV public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;I)V public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;)V public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;Z)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Z)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/String;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 36acc220fe1..8ae3eff7ea9 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -46,9 +46,9 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory -import io.getstream.chat.android.state.event.handler.chat.factory.GroupAwareChatEventHandlerFactory +import io.getstream.chat.android.state.event.handler.chat.factory.groupAwareChatEventHandlerFactory import io.getstream.chat.android.state.extensions.globalStateFlow -import io.getstream.chat.android.state.extensions.initQueryChannelsAsState +import io.getstream.chat.android.state.extensions.initGroupedQueryChannelsAsState import io.getstream.chat.android.state.extensions.queryChannelsAsState import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData @@ -146,10 +146,10 @@ public class ChannelListViewModel internal constructor( * Grouped channel list constructor. Subscribes to the state identified by [groupKey] without * issuing a remote call; the state is populated externally by `queryGroupedChannels` responses. * - * Defaults [chatEventHandlerFactory] to a [GroupAwareChatEventHandlerFactory] keyed on - * [groupKey] so that `channel.updated` and channel-add events route channels into the - * correct group out of the box. Pass a custom factory (e.g. with a custom - * [io.getstream.chat.android.state.event.handler.chat.ChannelGroupResolver]) to override. + * Internally builds a group-aware [ChatEventHandlerFactory] keyed on [groupKey] so that + * `channel.updated` and channel-add events route channels into the correct group based on the + * event's `channel_custom` map. This is not customizable yet; the routing contract is still + * settling. */ public constructor( chatClient: ChatClient, @@ -157,10 +157,6 @@ public class ChannelListViewModel internal constructor( channelLimit: Int = DEFAULT_CHANNEL_LIMIT, memberLimit: Int? = null, messageLimit: Int? = null, - chatEventHandlerFactory: ChatEventHandlerFactory = GroupAwareChatEventHandlerFactory( - groupKey = groupKey, - clientState = chatClient.clientState, - ), searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, isDraftMessageEnabled: Boolean = false, messageSearchSort: QuerySorter? = null, @@ -171,7 +167,10 @@ public class ChannelListViewModel internal constructor( channelLimit = channelLimit, memberLimit = memberLimit, messageLimit = messageLimit, - chatEventHandlerFactory = chatEventHandlerFactory, + chatEventHandlerFactory = groupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = chatClient.clientState, + ), searchDebounceMs = searchDebounceMs, isDraftMessageEnabled = isDraftMessageEnabled, messageSearchSort = messageSearchSort, @@ -363,7 +362,7 @@ public class ChannelListViewModel internal constructor( * - **Standard**: build a standard `QueryChannelsRequest` from filter/sort and issue * `queryChannelsAsState`. * - **Grouped + no active channel search**: subscribe to the identifier-keyed state via - * `initQueryChannelsAsState`. No remote call; `queryGroupedChannels` responses populate + * `initGroupedQueryChannelsAsState`. No remote call; `queryGroupedChannels` responses populate * the state via the listener. * - **Grouped + active channel search**: fall back to a standalone `queryChannelsAsState` * using [optimizedChannelSearchFilter]. @@ -563,7 +562,7 @@ public class ChannelListViewModel internal constructor( */ private fun observeGroupedChannels(groupKey: String) = observeQueryChannelsInternal(tag = "observeGroupedChannels") { - chatClient.initQueryChannelsAsState( + chatClient.initGroupedQueryChannelsAsState( identifier = QueryChannelsIdentifier.Grouped(groupKey), chatEventHandlerFactory = chatEventHandlerFactory, coroutineScope = chListScope, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index f8c63c2b712..bd33a6da7d1 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -27,7 +27,7 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory -import io.getstream.chat.android.state.event.handler.chat.factory.GroupAwareChatEventHandlerFactory +import io.getstream.chat.android.state.event.handler.chat.factory.groupAwareChatEventHandlerFactory import io.getstream.chat.android.state.extensions.globalStateFlow /** @@ -95,12 +95,15 @@ public class ChannelViewModelFactory internal constructor( * Grouped [ChannelListViewModel] factory. Wires the ViewModel to the state identified by * [groupKey] without firing a remote call; `queryGroupedChannels` responses populate it. * + * Internally builds a group-aware [ChatEventHandlerFactory] keyed on [groupKey] that routes + * channels based on the inbound event's `channel_custom` map. This is not customizable yet; + * the routing contract is still settling. + * * @param chatClient The client used to fetch data. * @param groupKey Identifies the group whose state this ViewModel observes. * @param channelLimit How many channels we fetch per page. * @param memberLimit Members fetched per channel. When `null`, server-side default is used. * @param messageLimit Messages fetched per channel. When `null`, server-side default is used. - * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. * @param isDraftMessageEnabled If the draft message feature is enabled. * @param messageSearchSort Optional sorting for message search results. */ @@ -111,10 +114,6 @@ public class ChannelViewModelFactory internal constructor( channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, memberLimit: Int? = null, messageLimit: Int? = null, - chatEventHandlerFactory: ChatEventHandlerFactory = GroupAwareChatEventHandlerFactory( - groupKey = groupKey, - clientState = chatClient.clientState, - ), isDraftMessageEnabled: Boolean = false, messageSearchSort: QuerySorter? = null, ) : this( @@ -123,7 +122,10 @@ public class ChannelViewModelFactory internal constructor( channelLimit = channelLimit, memberLimit = memberLimit, messageLimit = messageLimit, - chatEventHandlerFactory = chatEventHandlerFactory, + chatEventHandlerFactory = groupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = chatClient.clientState, + ), isDraftMessageEnabled = isDraftMessageEnabled, messageSearchSort = messageSearchSort, ) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt index 18111074407..e8f806dd471 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -626,7 +626,6 @@ internal class ChannelListViewModelTest { chatClient = chatClient, groupKey = groupKey, isDraftMessageEnabled = false, - chatEventHandlerFactory = ChatEventHandlerFactory(clientState), messageSearchSort = messageSearchSort, globalState = MutableStateFlow(globalState), ) diff --git a/stream-chat-android-state/api/stream-chat-android-state.api b/stream-chat-android-state/api/stream-chat-android-state.api index 33dbcee7fd3..6ae393877ed 100644 --- a/stream-chat-android-state/api/stream-chat-android-state.api +++ b/stream-chat-android-state/api/stream-chat-android-state.api @@ -5,27 +5,10 @@ public abstract class io/getstream/chat/android/state/event/handler/chat/BaseCha public fun handleCidEvent (Lio/getstream/chat/android/client/events/CidEvent;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; } -public abstract interface class io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver { - public abstract fun resolve (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Ljava/util/Set; -} - public abstract interface class io/getstream/chat/android/state/event/handler/chat/ChatEventHandler { public abstract fun handleChatEvent (Lio/getstream/chat/android/client/events/ChatEvent;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; } -public final class io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver : io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver { - public static final field Companion Lio/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver$Companion; - public static final field DEFAULT_ALL_GROUP_KEY Ljava/lang/String; - public static final field DEFAULT_GROUP_FIELD_NAME Ljava/lang/String; - public fun ()V - public fun (Ljava/lang/String;Ljava/lang/String;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun resolve (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Ljava/util/Set; -} - -public final class io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver$Companion { -} - public class io/getstream/chat/android/state/event/handler/chat/DefaultChatEventHandler : io/getstream/chat/android/state/event/handler/chat/BaseChatEventHandler { public fun (Lkotlinx/coroutines/flow/StateFlow;Lio/getstream/chat/android/client/setup/state/ClientState;)V protected final fun addIfChannelIsAbsent (Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; @@ -78,14 +61,6 @@ public final class io/getstream/chat/android/state/event/handler/chat/EventHandl public fun toString ()Ljava/lang/String; } -public class io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler : io/getstream/chat/android/state/event/handler/chat/DefaultChatEventHandler { - public fun (Ljava/lang/String;Lio/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver;Lkotlinx/coroutines/flow/StateFlow;Lio/getstream/chat/android/client/setup/state/ClientState;)V - protected final fun getGroupKey ()Ljava/lang/String; - protected final fun getResolver ()Lio/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver; - public fun handleChannelEvent (Lio/getstream/chat/android/client/events/HasChannel;Lio/getstream/chat/android/models/FilterObject;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; - public fun handleCidEvent (Lio/getstream/chat/android/client/events/CidEvent;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/state/event/handler/chat/EventHandlingResult; -} - public class io/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory { public fun ()V public fun (Lio/getstream/chat/android/client/setup/state/ClientState;)V @@ -93,12 +68,6 @@ public class io/getstream/chat/android/state/event/handler/chat/factory/ChatEven public fun chatEventHandler (Lkotlinx/coroutines/flow/StateFlow;)Lio/getstream/chat/android/state/event/handler/chat/ChatEventHandler; } -public class io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory : io/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory { - public fun (Ljava/lang/String;Lio/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver;Lio/getstream/chat/android/client/setup/state/ClientState;)V - public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver;Lio/getstream/chat/android/client/setup/state/ClientState;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun chatEventHandler (Lkotlinx/coroutines/flow/StateFlow;)Lio/getstream/chat/android/state/event/handler/chat/ChatEventHandler; -} - public final class io/getstream/chat/android/state/extensions/ChatClientExtensions { public static final fun cancelEphemeralMessage (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/Message;)Lio/getstream/result/call/Call; public static final fun downloadAttachment (Lio/getstream/chat/android/client/ChatClient;Landroid/content/Context;Lio/getstream/chat/android/models/Attachment;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lio/getstream/result/call/Call; diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt index 25ec3eb66a4..4ab83fd959a 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt @@ -16,25 +16,27 @@ package io.getstream.chat.android.state.event.handler.chat -import io.getstream.chat.android.models.Channel - /** - * Resolves the set of group keys a [Channel] belongs to for the purposes of grouped channel - * lists driven by `queryGroupedChannels`. + * Resolves the set of group keys carried by an event for the purposes of grouped channel lists + * driven by `queryGroupedChannels`. * * Used by [GroupAwareChatEventHandler] to decide whether an incoming event-bearing channel * should be added to, removed from, or skipped by a query identified by * [io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier.Grouped]. + * The classification is read from `event.channelCustom` rather than `channel.extraData` because + * the cached/event-bearing channel can lag the server while the event itself carries the + * authoritative custom map. */ -public fun interface ChannelGroupResolver { +internal fun interface ChannelGroupResolver { /** - * @param channel The channel whose group membership is being resolved. + * @param channelCustom The `channel_custom` map from the inbound event, or `null` when the + * event does not carry one. * @param currentGroup The group key of the query asking. Most resolvers will not need this, * but it allows a single resolver instance to be shared across multiple grouped queries and * still differentiate behavior per asker (e.g. logging, short-circuiting, per-group rules). * @return The set of group keys this channel belongs to. A channel can belong to multiple * groups (e.g. an explicit group plus an `"all"` sentinel). */ - public fun resolve(channel: Channel, currentGroup: String): Set + fun resolve(channelCustom: Map?, currentGroup: String): Set } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt index 284136c52c0..b2f8126ec38 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt @@ -16,31 +16,30 @@ package io.getstream.chat.android.state.event.handler.chat -import io.getstream.chat.android.models.Channel - /** - * Default [ChannelGroupResolver] backed by `channel.extraData`. + * Default [ChannelGroupResolver] backed by `event.channelCustom`. * - * Reads an explicit group key from `channel.extraData[groupFieldName]` (defaults to `"group"`) + * Reads an explicit group key from `channelCustom[groupFieldName]` (defaults to `"group"`) * and always includes the [allGroupKey] sentinel (defaults to `"all"`) so that a designated * "all channels" grouped query always sees every channel. * - * @param groupFieldName The key in `channel.extraData` carrying the explicit group identifier. + * @param groupFieldName The key in the event's `channel_custom` map carrying the explicit group + * identifier. * @param allGroupKey The sentinel group key representing "every channel". Pass `null` to disable * the implicit sentinel. */ -public class DefaultChannelGroupResolver( +internal class DefaultChannelGroupResolver( private val groupFieldName: String = DEFAULT_GROUP_FIELD_NAME, private val allGroupKey: String? = DEFAULT_ALL_GROUP_KEY, ) : ChannelGroupResolver { - override fun resolve(channel: Channel, currentGroup: String): Set = buildSet { - (channel.extraData[groupFieldName] as? String)?.let(::add) + override fun resolve(channelCustom: Map?, currentGroup: String): Set = buildSet { + (channelCustom?.get(groupFieldName) as? String)?.let(::add) allGroupKey?.let(::add) } - public companion object { - public const val DEFAULT_GROUP_FIELD_NAME: String = "group" - public const val DEFAULT_ALL_GROUP_KEY: String = "all" + companion object { + const val DEFAULT_GROUP_FIELD_NAME: String = "group" + const val DEFAULT_ALL_GROUP_KEY: String = "all" } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt index 3b91da77b16..f732170510f 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt @@ -21,6 +21,7 @@ import io.getstream.chat.android.client.events.ChannelUpdatedEvent import io.getstream.chat.android.client.events.ChannelVisibleEvent import io.getstream.chat.android.client.events.CidEvent import io.getstream.chat.android.client.events.HasChannel +import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationMessageNewEvent import io.getstream.chat.android.client.setup.state.ClientState @@ -30,56 +31,47 @@ import kotlinx.coroutines.flow.StateFlow /** * [ChatEventHandler] that routes channels in and out of a grouped channel list based on the - * channel's resolved group(s). + * group key carried by the inbound event's `channel_custom` map. * * Intended to be paired with `QueryChannelsIdentifier.Grouped(groupKey)` — one handler instance - * per grouped query. On every event carrying full channel data (e.g. [ChannelUpdatedEvent]), - * the handler asks the [resolver] which groups the channel belongs to: - * - If [groupKey] is in the set and the channel is not currently in this list, [EventHandlingResult.Add]. - * - If [groupKey] is not in the set and the channel IS currently in this list, [EventHandlingResult.Remove]. - * - Otherwise [EventHandlingResult.Skip] (no state churn for re-adding already-present channels - * nor for ignoring non-members). + * per grouped query. Classification is performed against `event.channelCustom` rather than + * `channel.extraData` because the cached channel can lag the server while the event itself + * carries the authoritative custom map. * - * For events that carry only a `cid` (e.g. [io.getstream.chat.android.client.events.MemberAddedEvent]), - * the handler delegates to [DefaultChatEventHandler] and then filters any resulting `Add` through - * the resolver, using the supplied `cachedChannel` as the input to the group lookup. + * For channel-bearing events ([ChannelUpdatedEvent], [ChannelUpdatedByUserEvent]): + * - If [groupKey] is in the resolved set and the channel is not currently in this list, + * [EventHandlingResult.Add]. + * - If [groupKey] is not in the resolved set and the channel IS currently in this list, + * [EventHandlingResult.Remove]. + * - Otherwise [EventHandlingResult.Skip]. * - * Removal events (`ChannelDeletedEvent`, `ChannelHiddenEvent`, `MemberRemovedEvent` for the - * current user, etc.) are inherited from [DefaultChatEventHandler] unchanged — leaving a channel - * removes it from any list it was in, regardless of group. + * For watch-and-add events ([NotificationAddedToChannelEvent], [NotificationMessageNewEvent], + * [ChannelVisibleEvent]): emits [EventHandlingResult.WatchAndAdd] when the event's + * `channel_custom` says the channel belongs here, otherwise [EventHandlingResult.Skip]. * - * @param groupKey The group identifier this handler is responsible for. - * @param resolver Decides which group keys a channel belongs to. - * @param channels Visible-channel map for this grouped query (used to gate Remove decisions). - * @param clientState Used for membership checks inherited from [DefaultChatEventHandler]. + * For [NewMessageEvent]: filtered up-front in [handleCidEvent] using `event.channelCustom` before + * [DefaultChatEventHandler] gets a chance to `Add(cachedChannel)`. Off-group messages produce + * [EventHandlingResult.Skip]. + * + * Member events (`MemberAddedEvent`/`MemberUpdatedEvent`/`MemberRemovedEvent`) and other CID-only + * events do not carry `channel_custom`, so they delegate to [DefaultChatEventHandler] unchanged. + * This means a user added to a channel in another group can briefly appear in this list until the + * follow-up `channel.updated` arrives and [routeByGroup] reclassifies it. */ -public open class GroupAwareChatEventHandler( - protected val groupKey: String, - protected val resolver: ChannelGroupResolver, +internal class GroupAwareChatEventHandler( + private val groupKey: String, + private val resolver: ChannelGroupResolver, channels: StateFlow?>, clientState: ClientState, ) : DefaultChatEventHandler(channels, clientState) { override fun handleChannelEvent(event: HasChannel, filter: FilterObject): EventHandlingResult { return when (event) { - // ChannelUpdated[ByUser]Event: re-route by the channel's current group. - is ChannelUpdatedEvent, - is ChannelUpdatedByUserEvent, - -> routeByGroup(event.channel) - - // Channel-bearing add events: only watch+add if the channel belongs in this group. - // Mirrors the default's WatchAndAdd choice but gated by the resolver against the - // event's channel snapshot. - is NotificationAddedToChannelEvent, - is NotificationMessageNewEvent, - is ChannelVisibleEvent, - -> if (channelBelongsHere(event.channel)) { - EventHandlingResult.WatchAndAdd(event.cid) - } else { - EventHandlingResult.Skip - } - - // Removes/visibility-loss and everything else: inherit default behavior. + is ChannelUpdatedEvent -> routeByGroup(event.channel, event.channelCustom) + is ChannelUpdatedByUserEvent -> routeByGroup(event.channel, event.channelCustom) + is NotificationAddedToChannelEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) + is NotificationMessageNewEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) + is ChannelVisibleEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) else -> super.handleChannelEvent(event, filter) } } @@ -89,17 +81,18 @@ public open class GroupAwareChatEventHandler( filter: FilterObject, cachedChannel: Channel?, ): EventHandlingResult { - val defaultResult = super.handleCidEvent(event, filter, cachedChannel) - return filterResultByGroup(defaultResult, cachedChannel) + if (event is NewMessageEvent && !belongsHere(event.channelCustom)) { + return EventHandlingResult.Skip + } + return super.handleCidEvent(event, filter, cachedChannel) } /** - * Routes a channel-bearing event to Add / Remove / Skip based on the channel's resolved groups - * and whether it is currently in this grouped list. Re-adding an already-present channel is - * skipped — channel-state updates flow through a separate pipeline, so we don't churn the list. + * Routes a channel-bearing event to Add / Remove / Skip based on the group resolved from the + * event's `channelCustom` and whether the channel is currently in this grouped list. */ - private fun routeByGroup(channel: Channel): EventHandlingResult { - val belongsHere = channelBelongsHere(channel) + private fun routeByGroup(channel: Channel, channelCustom: Map?): EventHandlingResult { + val belongsHere = belongsHere(channelCustom) val isInList = channels.value?.containsKey(channel.cid) == true return when { belongsHere && !isInList -> EventHandlingResult.Add(channel) @@ -108,28 +101,13 @@ public open class GroupAwareChatEventHandler( } } - private fun channelBelongsHere(channel: Channel): Boolean = - resolver.resolve(channel, groupKey).contains(groupKey) + private fun watchAndAddIfBelongs(cid: String, channelCustom: Map?): EventHandlingResult = + if (belongsHere(channelCustom)) { + EventHandlingResult.WatchAndAdd(cid) + } else { + EventHandlingResult.Skip + } - /** - * Downgrades an `Add`/`WatchAndAdd` from the default handler to `Skip` if the resolver says - * the channel does not belong in this group. `Remove`/`Skip` pass through unchanged. - */ - private fun filterResultByGroup( - result: EventHandlingResult, - cachedChannel: Channel?, - ): EventHandlingResult = when (result) { - is EventHandlingResult.Add -> - if (channelBelongsHere(result.channel)) result else EventHandlingResult.Skip - is EventHandlingResult.WatchAndAdd -> - // No channel data on the event; use cachedChannel if available. If we have nothing - // to resolve against, trust the default and rely on the subsequent channel.updated - // (which carries full channel data) to clean up. - if (cachedChannel != null && !channelBelongsHere(cachedChannel)) { - EventHandlingResult.Skip - } else { - result - } - else -> result - } + private fun belongsHere(channelCustom: Map?): Boolean = + resolver.resolve(channelCustom, groupKey).contains(groupKey) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt index 02323c087d9..b8167e29416 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.state.event.handler.chat.factory import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.state.event.handler.chat.ChannelGroupResolver import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler @@ -28,18 +29,10 @@ import kotlinx.coroutines.flow.StateFlow /** * Produces [GroupAwareChatEventHandler] instances for grouped channel lists. * - * Pair with `QueryChannelsIdentifier.Grouped(groupKey)` when initializing a grouped query's - * state via `ChannelListViewModel(chatClient, groupKey = ...)` so that `channel.updated` and - * channel-add events route channels into the correct group. - * - * @param groupKey The group identifier this factory is producing handlers for. - * @param resolver Decides which group keys a channel belongs to. Defaults to - * [DefaultChannelGroupResolver], which reads `channel.extraData["group"]` and always includes - * an implicit `"all"` sentinel. - * @param clientState Used by the inherited [io.getstream.chat.android.state.event.handler.chat.DefaultChatEventHandler] - * to perform current-user membership checks. + * Internal: external consumers should not construct this directly. Compose code reaches it via + * [groupAwareChatEventHandlerFactory], which is the only seam exposed across module boundaries. */ -public open class GroupAwareChatEventHandlerFactory( +internal class GroupAwareChatEventHandlerFactory( private val groupKey: String, private val resolver: ChannelGroupResolver = DefaultChannelGroupResolver(), private val clientState: ClientState = ChatClient.instance().clientState, @@ -53,3 +46,20 @@ public open class GroupAwareChatEventHandlerFactory( clientState = clientState, ) } + +/** + * Builds the group-aware [ChatEventHandlerFactory] used to drive grouped channel lists. + * + * Marked as [InternalStreamChatApi] because the underlying handler/factory/resolver classes are + * deliberately hidden — the grouped-channels contract is still settling and we do not yet want to + * commit to a public extension point. Consumers should instantiate a grouped + * `ChannelListViewModel` instead of calling this directly. + */ +@InternalStreamChatApi +public fun groupAwareChatEventHandlerFactory( + groupKey: String, + clientState: ClientState, +): ChatEventHandlerFactory = GroupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = clientState, +) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index 891ed64cd2b..75db387f396 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -40,6 +40,7 @@ import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.event.handler.chat.factory.groupAwareChatEventHandlerFactory import io.getstream.chat.android.state.extensions.internal.logic import io.getstream.chat.android.state.extensions.internal.parseAttachmentNameFromUrl import io.getstream.chat.android.state.extensions.internal.requestsAsState @@ -158,21 +159,23 @@ public fun ChatClient.queryChannelsAsState( * served by [queryChannelsAsState] instead. * * @param identifier The grouped query's identifier whose state should be initialized. - * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] that will be used to create [ChatEventHandler]. + * @param chatEventHandlerFactory The factory used to create the [ChatEventHandler] that routes + * events into this grouped list. Defaults to a group-aware factory keyed on [identifier]. * @param coroutineScope The [CoroutineScope] used for executing the request. * * @return A StateFlow that emits null until the user is connected, then emits the [QueryChannelsState] for the identifier. */ @InternalStreamChatApi @JvmOverloads -public fun ChatClient.initQueryChannelsAsState( +public fun ChatClient.initGroupedQueryChannelsAsState( identifier: QueryChannelsIdentifier.Grouped, - chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(clientState), + chatEventHandlerFactory: ChatEventHandlerFactory = + groupAwareChatEventHandlerFactory(groupKey = identifier.group, clientState = clientState), coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), ): StateFlow { - StreamLog.d(TAG) { "[initQueryChannelsAsState] identifier: $identifier" } + StreamLog.d(TAG) { "[initGroupedQueryChannelsAsState] identifier: $identifier" } return getStateOrNull(coroutineScope) { - requestsAsState(coroutineScope).initQueryChannelsState(identifier, chatEventHandlerFactory) + requestsAsState(coroutineScope).initGroupedQueryChannelsState(identifier, chatEventHandlerFactory) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt index 76173867eb3..7abd95c52f7 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt @@ -77,11 +77,11 @@ internal class ChatClientStateCalls( * launching a remote queryChannels API call. Channels cached under the identifier's DB key * are optimistically loaded into the state. */ - internal suspend fun initQueryChannelsState( + internal suspend fun initGroupedQueryChannelsState( identifier: QueryChannelsIdentifier.Grouped, chatEventHandlerFactory: ChatEventHandlerFactory, ): QueryChannelsState { - logger.d { "[initQueryChannelsState] identifier: $identifier" } + logger.d { "[initGroupedQueryChannelsState] identifier: $identifier" } chatClient.clientState.user.first { it != null } val state = deferredState .await() diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt index 3345e5a6e7d..93b3927dee4 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.state.event.handler.chat -import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomString import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test @@ -24,51 +23,59 @@ import org.junit.jupiter.api.Test internal class DefaultChannelGroupResolverTest { @Test - fun `Given a channel with an explicit group When resolved Then returns the group plus the all sentinel`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + fun `Given a channel_custom with an explicit group When resolved Then returns the group plus the all sentinel`() { + val channelCustom: Map = mapOf("group" to "vip") val resolver = DefaultChannelGroupResolver() - val result = resolver.resolve(channel, currentGroup = randomString()) + val result = resolver.resolve(channelCustom, currentGroup = randomString()) assertEquals(setOf("vip", "all"), result) } @Test - fun `Given a channel with no group extra When resolved Then returns only the all sentinel`() { - val channel = randomChannel(extraData = emptyMap()) + fun `Given a null channel_custom When resolved Then returns only the all sentinel`() { val resolver = DefaultChannelGroupResolver() - val result = resolver.resolve(channel, currentGroup = randomString()) + val result = resolver.resolve(channelCustom = null, currentGroup = randomString()) + + assertEquals(setOf("all"), result) + } + + @Test + fun `Given an empty channel_custom When resolved Then returns only the all sentinel`() { + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channelCustom = emptyMap(), currentGroup = randomString()) assertEquals(setOf("all"), result) } @Test fun `Given a custom group field name When resolved Then reads that field`() { - val channel = randomChannel(extraData = mapOf("tier" to "gold", "group" to "ignored")) + val channelCustom: Map = mapOf("tier" to "gold", "group" to "ignored") val resolver = DefaultChannelGroupResolver(groupFieldName = "tier") - val result = resolver.resolve(channel, currentGroup = randomString()) + val result = resolver.resolve(channelCustom, currentGroup = randomString()) assertEquals(setOf("gold", "all"), result) } @Test fun `Given the all sentinel is disabled When resolved Then returns only the explicit group`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channelCustom: Map = mapOf("group" to "vip") val resolver = DefaultChannelGroupResolver(allGroupKey = null) - val result = resolver.resolve(channel, currentGroup = randomString()) + val result = resolver.resolve(channelCustom, currentGroup = randomString()) assertEquals(setOf("vip"), result) } @Test - fun `Given a non-string group extra When resolved Then ignores it and returns only the all sentinel`() { - val channel = randomChannel(extraData = mapOf("group" to 42)) + fun `Given a non-string group value When resolved Then ignores it and returns only the all sentinel`() { + val channelCustom: Map = mapOf("group" to 42) val resolver = DefaultChannelGroupResolver() - val result = resolver.resolve(channel, currentGroup = randomString()) + val result = resolver.resolve(channelCustom, currentGroup = randomString()) assertEquals(setOf("all"), result) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt index a02200448c9..431e38314ba 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt @@ -21,12 +21,15 @@ import io.getstream.chat.android.client.test.randomChannelDeletedEvent import io.getstream.chat.android.client.test.randomChannelUpdatedEvent import io.getstream.chat.android.client.test.randomMemberAddedEvent import io.getstream.chat.android.client.test.randomMemberRemovedEvent +import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.User import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomUser import kotlinx.coroutines.flow.MutableStateFlow import org.junit.jupiter.api.Assertions.assertEquals @@ -41,9 +44,13 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to this group and is not cached When ChannelUpdatedEvent arrives Should add`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel() val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) - val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -52,9 +59,13 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to this group and is already cached When ChannelUpdatedEvent arrives Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel() val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) - val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -63,9 +74,13 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel moved to another group and is currently cached When ChannelUpdatedEvent arrives Should remove`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel() val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) - val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "other"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -74,9 +89,13 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs to another group and is not cached When ChannelUpdatedEvent arrives Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel() val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) - val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "other"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -85,9 +104,13 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given handler is for the all group When ChannelUpdatedEvent arrives Should always add`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel() val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap()) - val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -96,9 +119,13 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel does not belong here When NotificationAddedToChannelEvent arrives Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel() val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) - val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) + val event = randomNotificationAddedToChannelEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "other"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -107,9 +134,13 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel belongs here When NotificationAddedToChannelEvent arrives Should watch and add`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel() val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) - val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) + val event = randomNotificationAddedToChannelEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -118,9 +149,13 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given channel does not belong here When NotificationMessageNewEvent arrives Should skip`() { - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel() val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) - val event = randomNotificationMessageNewEvent(cid = channel.cid, channel = channel) + val event = randomNotificationMessageNewEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "other"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -128,17 +163,34 @@ internal class GroupAwareChatEventHandlerTest { } @Test - fun `Given current user joined with matching cached channel When MemberAddedEvent arrives Should add`() { - val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + fun `Given NewMessageEvent for another group Should skip without consulting super`() { + val channel = randomChannel() val handler = handlerFor( groupKey = "vip", cachedChannels = emptyMap(), - currentUser = currentUser, ) - val event = randomMemberAddedEvent( + val event = randomNewMessageEvent( cid = channel.cid, - member = randomMember(user = currentUser), + channelCustom = mapOf("group" to "other"), + ) + + // cachedChannel is provided; if super were consulted it could return Add. We assert Skip. + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given NewMessageEvent for this group with cached channel absent from list Should add`() { + val channel = randomChannel() + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + ) + val event = randomNewMessageEvent( + cid = channel.cid, + message = randomMessage(type = "regular"), + channelCustom = mapOf("group" to "vip"), ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) @@ -147,9 +199,37 @@ internal class GroupAwareChatEventHandlerTest { } @Test - fun `Given current user joined with non-matching cached channel When MemberAddedEvent arrives Should skip`() { + fun `Given NewMessageEvent for this group but no cached channel Should skip`() { + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNewMessageEvent( + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given system NewMessageEvent for this group Should skip via super`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNewMessageEvent( + cid = channel.cid, + message = randomMessage(type = "system"), + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given current user joined with a cached channel When MemberAddedEvent arrives Should add via super`() { + // Member events do not carry channel_custom and the handler trusts super (no group filter). val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "other")) + val channel = randomChannel() val handler = handlerFor( groupKey = "vip", cachedChannels = emptyMap(), @@ -162,13 +242,13 @@ internal class GroupAwareChatEventHandlerTest { val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) - assertEquals(EventHandlingResult.Skip, result) + assertEquals(EventHandlingResult.Add(channel), result) } @Test fun `Given current user left a cached channel When MemberRemovedEvent arrives Should remove regardless of group`() { val currentUser = randomUser() - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel() val handler = handlerFor( groupKey = "vip", cachedChannels = mapOf(channel.cid to channel), @@ -186,7 +266,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a cached channel When ChannelDeletedEvent arrives Should remove regardless of group`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel() val handler = handlerFor( groupKey = "vip", cachedChannels = mapOf(channel.cid to channel), @@ -200,16 +280,20 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given a custom resolver that reads a different field When ChannelUpdatedEvent arrives Should use custom field`() { - val channel = randomChannel(extraData = mapOf("tier" to "vip")) - val customResolver = ChannelGroupResolver { ch, _ -> - setOfNotNull(ch.extraData["tier"] as? String) + val channel = randomChannel() + val customResolver = ChannelGroupResolver { channelCustom, _ -> + setOfNotNull(channelCustom?.get("tier") as? String) } val handler = handlerFor( groupKey = "vip", cachedChannels = emptyMap(), resolver = customResolver, ) - val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("tier" to "vip"), + ) val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -218,7 +302,7 @@ internal class GroupAwareChatEventHandlerTest { @Test fun `Given custom resolver Should receive the handler's groupKey as currentGroup`() { - val channel = randomChannel(extraData = mapOf("group" to "vip")) + val channel = randomChannel() var capturedGroup: String? = null val capturingResolver = ChannelGroupResolver { _, currentGroup -> capturedGroup = currentGroup @@ -229,7 +313,11 @@ internal class GroupAwareChatEventHandlerTest { cachedChannels = emptyMap(), resolver = capturingResolver, ) - val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) @@ -240,7 +328,7 @@ internal class GroupAwareChatEventHandlerTest { groupKey: String, cachedChannels: Map, resolver: ChannelGroupResolver = defaultResolver, - currentUser: io.getstream.chat.android.models.User? = null, + currentUser: User? = null, ): GroupAwareChatEventHandler { val clientState = mock { whenever(it.user) doReturn MutableStateFlow(currentUser) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt index 921a0124e07..c3a3643038b 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt @@ -92,13 +92,13 @@ internal class ChatClientStateCallsTest { } @Test - fun `initQueryChannelsState creates state without API call`() = runTest { + fun `initGroupedQueryChannelsState creates state without API call`() = runTest { // Given - user is connected userFlow.value = User(id = "test-user") val factory = ChatEventHandlerFactory(clientState) // When - val result = chatClientStateCalls.initQueryChannelsState(identifier, factory) + val result = chatClientStateCalls.initGroupedQueryChannelsState(identifier, factory) // Then — no remote queryChannels API call; the offline grouped load runs locally. verify(chatClient, never()).queryChannels(any()) @@ -106,14 +106,14 @@ internal class ChatClientStateCallsTest { } @Test - fun `initQueryChannelsState waits for user before proceeding`() = runTest { + fun `initGroupedQueryChannelsState waits for user before proceeding`() = runTest { // Given - user is NOT connected yet val factory = ChatEventHandlerFactory(clientState) var completed = false - // When - launch initQueryChannelsState (it should suspend waiting for user) + // When - launch initGroupedQueryChannelsState (it should suspend waiting for user) val job = launch { - chatClientStateCalls.initQueryChannelsState(identifier, factory) + chatClientStateCalls.initGroupedQueryChannelsState(identifier, factory) completed = true } advanceUntilIdle() @@ -131,13 +131,13 @@ internal class ChatClientStateCallsTest { } @Test - fun `initQueryChannelsState returns state matching the identifier`() = runTest { + fun `initGroupedQueryChannelsState returns state matching the identifier`() = runTest { // Given userFlow.value = User(id = "test-user") val factory = ChatEventHandlerFactory(clientState) // When - chatClientStateCalls.initQueryChannelsState(identifier, factory) + chatClientStateCalls.initGroupedQueryChannelsState(identifier, factory) // Then - stateRegistry.queryChannels should be called with the identifier verify(stateRegistry).queryChannels(identifier) From 5883f894efdc0cfe5727e9fde56d80f7f3fa11af Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 14 May 2026 16:00:30 +0200 Subject: [PATCH 18/21] Rename selectBy. --- .../api/stream-chat-android-client.api | 5 ++--- .../repository/QueryChannelsRepository.kt | 12 +++--------- .../repository/noop/NoOpQueryChannelsRepository.kt | 9 +++++---- .../internal/DatabaseQueryChannelsRepository.kt | 13 +++++++++++-- .../internal/QueryChannelsDatabaseLogic.kt | 8 ++++++-- .../internal/QueryChannelsDatabaseLogicTest.kt | 13 ++++++------- 6 files changed, 33 insertions(+), 27 deletions(-) diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index db90131b3a1..049f6fb249c 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -3100,9 +3100,8 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract interface class io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository { public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun insertQueryChannels (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun selectBy (Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun selectBy$suspendImpl (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun selectBy (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/persistance/repository/ReactionRepository { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt index 588868ba06f..d134d49e4e7 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.client.persistance.repository -import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject @@ -35,9 +34,9 @@ public interface QueryChannelsRepository { public suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) /** - * Selects the spec stored under [identifier]. + * Selects the spec stored under [groupKey]. */ - public suspend fun selectBy(identifier: QueryChannelsIdentifier): QueryChannelsSpec? + public suspend fun selectBy(groupKey: String): QueryChannelsSpec? /** * Selects by a filter and query sort. @@ -45,12 +44,7 @@ public interface QueryChannelsRepository { * @param filter [FilterObject] * @param querySort [QuerySorter] */ - @Deprecated( - message = "Use selectBy(identifier) instead.", - replaceWith = ReplaceWith("selectBy(QueryChannelsIdentifier.Standard(filter, querySort))"), - ) - public suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? = - selectBy(QueryChannelsIdentifier.Standard(filter, querySort)) + public suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? /** * Clear QueryChannels of this repository. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt index c77e98dabc2..ab8797f6ba9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt @@ -16,17 +16,18 @@ package io.getstream.chat.android.client.persistance.repository.noop -import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository import io.getstream.chat.android.client.query.QueryChannelsSpec +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.querysort.QuerySorter /** * No-Op QueryChannelsRepository. */ internal object NoOpQueryChannelsRepository : QueryChannelsRepository { override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) { /* No-Op */ } - - override suspend fun selectBy(identifier: QueryChannelsIdentifier): QueryChannelsSpec? = null - + override suspend fun selectBy(groupKey: String): QueryChannelsSpec? = null + override suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? = null override suspend fun clear() { /* No-Op */ } } diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt index 0dd64ebb003..a49b8808606 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt @@ -20,6 +20,9 @@ import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdent import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository import io.getstream.chat.android.client.query.QueryChannelsSpec +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.querysort.QuerySorter /** * Repository for queries of channels. This implementation uses the database. @@ -37,8 +40,14 @@ internal class DatabaseQueryChannelsRepository( queryChannelsDao.insert(toEntity(queryChannelsSpec)) } - override suspend fun selectBy(identifier: QueryChannelsIdentifier): QueryChannelsSpec? { - return queryChannelsDao.select(generateId(identifier))?.let(Companion::toModel) + override suspend fun selectBy(groupKey: String): QueryChannelsSpec? { + val identifier = QueryChannelsIdentifier.Grouped(groupKey) + return queryChannelsDao.select(generateId(identifier))?.let(::toModel) + } + + override suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? { + val identifier = QueryChannelsIdentifier.Standard(filter, querySort) + return queryChannelsDao.select(generateId(identifier))?.let(::toModel) } override suspend fun clear() { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt index 9552020ea21..8b3fd498ba5 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt @@ -17,7 +17,6 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.extensions.internal.applyPagination -import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.client.persistance.repository.ChannelConfigRepository import io.getstream.chat.android.client.persistance.repository.ChannelRepository import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository @@ -52,7 +51,12 @@ internal class QueryChannelsDatabaseLogic( queryChannelsSpec: QueryChannelsSpec?, ): List? { val cachedSpec = queryChannelsSpec?.let { - queryChannelsRepository.selectBy(it.identifier) + val groupKey = it.groupKey + if (groupKey != null) { + queryChannelsRepository.selectBy(groupKey) + } else { + queryChannelsRepository.selectBy(it.filter, it.querySort) + } } return if (cachedSpec != null) { // Spec is present in DB, fetch channels according to it diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt index 798afab3d94..1305bd698d9 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal -import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.persistance.repository.ChannelConfigRepository import io.getstream.chat.android.client.persistance.repository.ChannelRepository import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository @@ -90,14 +89,14 @@ internal class QueryChannelsDatabaseLogicTest { val pagination = AnyChannelPaginationRequest() val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) - whenever(queryChannelsRepository.selectBy(QueryChannelsIdentifier.Standard(filter, sort))) doReturn null + whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn null // When val result = logic.fetchChannelsFromCache(pagination, queryChannelsSpec) // Then assertNull(result) - verify(queryChannelsRepository).selectBy(QueryChannelsIdentifier.Standard(filter, sort)) + verify(queryChannelsRepository).selectBy(filter, sort) } @Test @@ -126,7 +125,7 @@ internal class QueryChannelsDatabaseLogicTest { val channel3 = randomChannel(id = "channel3", type = "messaging") val expectedChannels = listOf(channel1, channel2, channel3) - whenever(queryChannelsRepository.selectBy(QueryChannelsIdentifier.Standard(filter, sort))) doReturn cachedSpec + whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn cachedSpec whenever(repositoryFacade.selectChannels(listOf(cid1, cid2, cid3), pagination)) doReturn expectedChannels // When @@ -134,7 +133,7 @@ internal class QueryChannelsDatabaseLogicTest { // Then assertEquals(expectedChannels, result) - verify(queryChannelsRepository).selectBy(QueryChannelsIdentifier.Standard(filter, sort)) + verify(queryChannelsRepository).selectBy(filter, sort) verify(repositoryFacade).selectChannels(listOf(cid1, cid2, cid3), pagination) } @@ -152,7 +151,7 @@ internal class QueryChannelsDatabaseLogicTest { ) val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) - whenever(queryChannelsRepository.selectBy(QueryChannelsIdentifier.Standard(filter, sort))) doReturn cachedSpec + whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn cachedSpec whenever(repositoryFacade.selectChannels(emptyList(), pagination)) doReturn emptyList() // When @@ -160,7 +159,7 @@ internal class QueryChannelsDatabaseLogicTest { // Then assertEquals(emptyList(), result) - verify(queryChannelsRepository).selectBy(QueryChannelsIdentifier.Standard(filter, sort)) + verify(queryChannelsRepository).selectBy(filter, sort) verify(repositoryFacade).selectChannels(emptyList(), pagination) } From d3f2462c5a1346050987384340ee5ec68ba7a661 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 14 May 2026 19:42:57 +0200 Subject: [PATCH 19/21] Clean-up. --- .../android/client/api2/mapping/EventMapping.kt | 9 +++++++-- .../android/client/query/QueryChannelsSpec.kt | 2 +- .../handler/chat/GroupAwareChatEventHandler.kt | 4 ++++ .../state/querychannels/QueryChannelsState.kt | 17 +++++++++++++++-- .../android/state/sync/internal/SyncManager.kt | 10 +++++++++- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index 07b80e4a2ef..d5039413b8a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -474,8 +474,8 @@ internal class EventMapping( id = channel_id, type = channel_type, memberCount = channel_member_count ?: 0, - name = channel_custom?.get("name") as? String, - image = channel_custom?.get("image") as? String, + name = channel_custom?.get(CHANNEL_CUSTOM_NAME) as? String, + image = channel_custom?.get(CHANNEL_CUSTOM_IMAGE) as? String, ) NewMessageEvent( type = type, @@ -1321,4 +1321,9 @@ internal class EventMapping( rawData = rawData, ) } + + companion object { + private const val CHANNEL_CUSTOM_NAME = "name" + private const val CHANNEL_CUSTOM_IMAGE = "image" + } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt index b38f8330023..5ec578cec0f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt @@ -31,7 +31,7 @@ import io.getstream.chat.android.models.querysort.QuerySorter public data class QueryChannelsSpec( val filter: FilterObject, val querySort: QuerySorter, - val cids: Set = emptySet(), + var cids: Set = emptySet(), val groupKey: String? = null, ) { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt index f732170510f..ab38137bcb4 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt @@ -72,6 +72,10 @@ internal class GroupAwareChatEventHandler( is NotificationAddedToChannelEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) is NotificationMessageNewEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) is ChannelVisibleEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) + // TODO: MemberAddedEvent flicker — when the current user is added to a channel in + // another group, super may Add it here until the follow-up `channel.updated` arrives + // and routeByGroup reclassifies. Verify whether iOS exhibits the same behavior and + // align the contract before exposing the grouped path as stable. else -> super.handleChannelEvent(event, filter) } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt index 92f42cc2545..112b00cd3c5 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt @@ -31,10 +31,23 @@ public interface QueryChannelsState { /** If the channels need to be synced. */ public val recoveryNeeded: StateFlow - /** The filter is associated with this query channels state. */ + /** + * The filter associated with this query channels state. + * + * For grouped queries (states keyed by `QueryChannelsIdentifier.Grouped`), this is a + * placeholder ([io.getstream.chat.android.models.Filters.neutral]) — grouped lists are + * partitioned server-side and do not have a client-side filter. Do not rely on this value + * for grouped states. + */ public val filter: FilterObject - /** The sort object which requested for this query channels state. */ + /** + * The sort associated with this query channels state. + * + * For grouped queries (states keyed by `QueryChannelsIdentifier.Grouped`), this is a + * placeholder (`last_message_at` descending) — grouped lists are ordered server-side and + * do not have a client-side sort. Do not rely on this value for grouped states. + */ public val sort: QuerySorter /** The request for the current page. */ diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index f391580bc1a..1f750e9497c 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -452,7 +452,15 @@ internal class SyncManager( return } - when (val result = chatClient.queryGroupedChannels().await()) { + // TODO: Align with iOS before publishing! + val result = chatClient.queryGroupedChannels( + limit = null, + groups = null, + watch = true, + presence = true, + ).await() + + when (result) { is Result.Success -> logger.v { "[updateGroupedQueryChannels] succeeded (listener applied)" } is Result.Failure -> From fca4d31d8900aa2201a7e448b8d14aa0a27bad25 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 14 May 2026 19:51:02 +0200 Subject: [PATCH 20/21] Reduce public api for group-based ChannelListViewModel.kt. --- .../viewmodel/channels/ChannelListViewModel.kt | 9 +++------ .../viewmodel/channels/ChannelViewModelFactory.kt | 12 +++--------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 8ae3eff7ea9..e3d1f43444b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -154,9 +154,6 @@ public class ChannelListViewModel internal constructor( public constructor( chatClient: ChatClient, groupKey: String, - channelLimit: Int = DEFAULT_CHANNEL_LIMIT, - memberLimit: Int? = null, - messageLimit: Int? = null, searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, isDraftMessageEnabled: Boolean = false, messageSearchSort: QuerySorter? = null, @@ -164,9 +161,9 @@ public class ChannelListViewModel internal constructor( ) : this( chatClient = chatClient, mode = QueryMode.Grouped(groupKey), - channelLimit = channelLimit, - memberLimit = memberLimit, - messageLimit = messageLimit, + channelLimit = DEFAULT_CHANNEL_LIMIT, + memberLimit = null, + messageLimit = null, chatEventHandlerFactory = groupAwareChatEventHandlerFactory( groupKey = groupKey, clientState = chatClient.clientState, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index bd33a6da7d1..53872b69950 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -101,9 +101,6 @@ public class ChannelViewModelFactory internal constructor( * * @param chatClient The client used to fetch data. * @param groupKey Identifies the group whose state this ViewModel observes. - * @param channelLimit How many channels we fetch per page. - * @param memberLimit Members fetched per channel. When `null`, server-side default is used. - * @param messageLimit Messages fetched per channel. When `null`, server-side default is used. * @param isDraftMessageEnabled If the draft message feature is enabled. * @param messageSearchSort Optional sorting for message search results. */ @@ -111,17 +108,14 @@ public class ChannelViewModelFactory internal constructor( public constructor( chatClient: ChatClient = ChatClient.instance(), groupKey: String, - channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, - memberLimit: Int? = null, - messageLimit: Int? = null, isDraftMessageEnabled: Boolean = false, messageSearchSort: QuerySorter? = null, ) : this( chatClient = chatClient, mode = QueryMode.Grouped(groupKey), - channelLimit = channelLimit, - memberLimit = memberLimit, - messageLimit = messageLimit, + channelLimit = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit = null, + messageLimit = null, chatEventHandlerFactory = groupAwareChatEventHandlerFactory( groupKey = groupKey, clientState = chatClient.clientState, From 8900613a7dc6410f306d42fa9a79b0dfed5b1bee Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 14 May 2026 19:56:37 +0200 Subject: [PATCH 21/21] ApiDump. --- .../api/stream-chat-android-client.api | 1 + .../api/stream-chat-android-compose.api | 13 +++++-------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 049f6fb249c..8016bfce676 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -3532,6 +3532,7 @@ public final class io/getstream/chat/android/client/query/QueryChannelsSpec { public final fun getGroupKey ()Ljava/lang/String; public final fun getQuerySort ()Lio/getstream/chat/android/models/querysort/QuerySorter; public fun hashCode ()I + public final fun setCids (Ljava/util/Set;)V public fun toString ()Ljava/lang/String; } diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 9d640557e71..99b02083727 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4896,8 +4896,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public static final field $stable I public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun deleteConversation (Lio/getstream/chat/android/models/Channel;)V public final fun dismissChannelAction ()V @@ -4940,12 +4940,9 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelV public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;I)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;Z)V - public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V - public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Z)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/String;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; }