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 35416c917b1..7d23abd61a7 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 @@ -662,7 +662,17 @@ public fun randomQueryChannelsSpec( filter: FilterObject = NeutralFilterObject, sort: QuerySorter = QuerySortByField(), cids: Set = emptySet(), -): QueryChannelsSpec = QueryChannelsSpec(filter, sort).apply { this.cids = cids } + predefinedFilterName: String? = null, + predefinedFilterValues: Map? = null, + predefinedSortValues: Map? = null, +): QueryChannelsSpec = QueryChannelsSpec( + filter = filter, + querySort = sort, + cids = cids, + predefinedFilterName = predefinedFilterName, + predefinedFilterValues = predefinedFilterValues, + predefinedSortValues = predefinedSortValues, +) 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 977284c9bae..7a79679d2d6 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -582,25 +582,39 @@ public class io/getstream/chat/android/client/api/models/QueryChannelRequest : i } public final class io/getstream/chat/android/client/api/models/QueryChannelsRequest : io/getstream/chat/android/client/api/models/ChannelRequest { + public fun (I)V + public fun (Lio/getstream/chat/android/models/FilterObject;I)V + public fun (Lio/getstream/chat/android/models/FilterObject;II)V + public fun (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;)V + public fun (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;)V public fun (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;)V + public fun (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/Map;)V + public fun (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/FilterObject; public final fun component2 ()I public final fun component3 ()I public final fun component4 ()Lio/getstream/chat/android/models/querysort/QuerySorter; public final fun component5 ()Ljava/lang/Integer; public final fun component6 ()Ljava/lang/Integer; - public final fun copy (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/chat/android/client/api/models/QueryChannelsRequest; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/api/models/QueryChannelsRequest; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/util/Map; + public final fun component9 ()Ljava/util/Map; + public final fun copy (Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)Lio/getstream/chat/android/client/api/models/QueryChannelsRequest; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lio/getstream/chat/android/models/FilterObject;IILio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/api/models/QueryChannelsRequest; public fun equals (Ljava/lang/Object;)Z public final fun getFilter ()Lio/getstream/chat/android/models/FilterObject; + public final fun getFilterValues ()Ljava/util/Map; public final fun getLimit ()I public final fun getMemberLimit ()Ljava/lang/Integer; public final fun getMessageLimit ()Ljava/lang/Integer; public final fun getOffset ()I + public final fun getPredefinedFilter ()Ljava/lang/String; public fun getPresence ()Z public final fun getQuerySort ()Lio/getstream/chat/android/models/querysort/QuerySorter; public final fun getSort ()Ljava/util/List; + public final fun getSortValues ()Ljava/util/Map; public fun getState ()Z public fun getWatch ()Z public fun hashCode ()I @@ -3051,6 +3065,34 @@ public final class io/getstream/chat/android/client/internal/offline/repository/ public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Predefined : io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier { + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Map; + public final fun component3 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Predefined; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Predefined;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Predefined; + public fun equals (Ljava/lang/Object;)Z + public final fun getFilterValues ()Ljava/util/Map; + public final fun getName ()Ljava/lang/String; + public final fun getSortValues ()Ljava/util/Map; + 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; @@ -3275,16 +3317,26 @@ 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;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;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 component5 ()Ljava/util/Map; + public final fun component6 ()Ljava/util/Map; 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;Ljava/util/Map;Ljava/util/Map;)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;Ljava/util/Map;Ljava/util/Map;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 getPredefinedFilterName ()Ljava/lang/String; + public final fun getPredefinedFilterValues ()Ljava/util/Map; + public final fun getPredefinedSortValues ()Ljava/util/Map; 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-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 cd4b59a760b..4e3f870cddb 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 @@ -2974,7 +2974,7 @@ internal constructor( @CheckResult @InternalStreamChatApi public fun queryChannelsInternal(request: QueryChannelsRequest): Call> { - return api.queryChannels(request) + return api.queryChannels(request).map { it.channels } } /** @@ -3001,7 +3001,7 @@ internal constructor( this.watch = false this.state = state } - when (val result = api.queryChannels(request).await()) { + when (val result = api.queryChannels(request).map { it.channels }.await()) { is Result.Success -> { val channels = result.value if (channels.isEmpty()) { @@ -3112,7 +3112,7 @@ internal constructor( @CheckResult public fun queryChannels(request: QueryChannelsRequest): Call> { logger.d { "[queryChannels] offset: ${request.offset}, limit: ${request.limit}" } - return queryChannelsInternal(request = request).doOnStart(userScope) { + return api.queryChannels(request).doOnStart(userScope) { plugins.forEach { listener -> logger.v { "[queryChannels] #doOnStart; plugin: ${listener::class.qualifiedName}" } listener.onQueryChannelsRequest(request) @@ -3124,6 +3124,8 @@ internal constructor( } }.precondition(plugins) { onQueryChannelsPrecondition(request) + }.map { + it.channels }.share(userScope) { QueryChannelsIdentifier(request) } 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 5dc799c49ed..634ecb7ab71 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 @@ -21,6 +21,7 @@ import io.getstream.chat.android.client.api.models.GetThreadOptions import io.getstream.chat.android.client.api.models.PinnedMessagesPagination import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.api.models.QueryUsersRequest import io.getstream.chat.android.client.api.models.SendActionRequest @@ -285,7 +286,7 @@ internal interface ChatApi { ): Call> @CheckResult - fun queryChannels(query: QueryChannelsRequest): Call> + fun queryChannels(query: QueryChannelsRequest): Call @CheckResult fun updateUsers(users: List): Call> diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/internal/DistinctChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/internal/DistinctChatApi.kt index d523419ac50..88c77f56b97 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/internal/DistinctChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/internal/DistinctChatApi.kt @@ -20,6 +20,7 @@ import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.api.models.PinnedMessagesPagination import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.api2.optimisation.hash.ChannelQueryKey import io.getstream.chat.android.client.api2.optimisation.hash.GetNewerRepliesHash import io.getstream.chat.android.client.api2.optimisation.hash.GetPinnedMessagesHash @@ -133,7 +134,7 @@ internal class DistinctChatApi( } } - override fun queryChannels(query: QueryChannelsRequest): Call> { + override fun queryChannels(query: QueryChannelsRequest): Call { val uniqueKey = query.hashCode() StreamLog.d(TAG) { "[queryChannels] query: $query, uniqueKey: $uniqueKey" } return getOrCreate(uniqueKey) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/internal/DistinctChatApiEnabler.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/internal/DistinctChatApiEnabler.kt index 7a08c71034e..64ec3af31e2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/internal/DistinctChatApiEnabler.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/internal/DistinctChatApiEnabler.kt @@ -20,6 +20,7 @@ import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.api.models.PinnedMessagesPagination import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.models.BannedUser import io.getstream.chat.android.models.BannedUsersSort import io.getstream.chat.android.models.Channel @@ -77,7 +78,7 @@ internal class DistinctChatApiEnabler( return getApi().getPinnedMessages(channelType, channelId, limit, sort, pagination) } - override fun queryChannels(query: QueryChannelsRequest): Call> { + override fun queryChannels(query: QueryChannelsRequest): Call { return getApi().queryChannels(query) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/PredefinedFilter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/PredefinedFilter.kt new file mode 100644 index 00000000000..369729e7c0b --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/PredefinedFilter.kt @@ -0,0 +1,36 @@ +/* + * 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.api.models + +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 + +/** + * Represents a predefined filter parsed by the backend. + * + * @param name The name/identifier of the predefined filter. + * @param filter The parsed filter specification. + * @param sort The parsed sort specification, or null if no sort was provided. + */ +@InternalStreamChatApi +public data class PredefinedFilter( + val name: String, + val filter: FilterObject, + val sort: QuerySorter?, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryChannelsRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryChannelsRequest.kt index 88416385d64..fd8fd39ef31 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryChannelsRequest.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryChannelsRequest.kt @@ -18,26 +18,36 @@ package io.getstream.chat.android.client.api.models 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.models.querysort.QuerySorter /** * Request body class for querying channels. * - * @property filter [FilterObject] conditions used by backend to filter queries response. + * @property filter [FilterObject] conditions used by backend to filter queries response. If [predefinedFilter] is + * specified, this field is ignored. * @property offset Pagination offset. * @property limit Number of channels to be returned by this query channels request. - * @property querySort [QuerySorter] Sort specification for api queries. + * @property querySort [QuerySorter] Sort specification for api queries. If [predefinedFilter] is specified, this field + * is ignored. * @property messageLimit Number of messages in the response. When `null`, the server-side default is used. * @property memberLimit Number of members in the response. When `null`, the server-side default is used. + * @property predefinedFilter ID of a server-side predefined filter to use instead of [filter]. + * When set, [filter] and [querySort] are ignored by the backend. + * @property filterValues Values to interpolate into the predefined filter template. + * @property sortValues Values to interpolate into the predefined sort template. */ -public data class QueryChannelsRequest( - public val filter: FilterObject, +public data class QueryChannelsRequest @JvmOverloads constructor( + public val filter: FilterObject = Filters.neutral(), public var offset: Int = 0, public var limit: Int, public val querySort: QuerySorter = QuerySortByField(), public var messageLimit: Int? = null, public var memberLimit: Int? = null, + public val predefinedFilter: String? = null, + public val filterValues: Map? = null, + public val sortValues: Map? = null, ) : ChannelRequest { override var state: Boolean = true diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryChannelsResult.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryChannelsResult.kt new file mode 100644 index 00000000000..68776abcac8 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryChannelsResult.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.api.models + +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Channel + +/** + * Result wrapper for [io.getstream.chat.android.client.api.ChatApi.queryChannels]. + * Holds both the list of channels and the optional parsed predefined filter returned by the backend. + * + * @param channels The list of channels returned by the query. + * @param predefinedFilter The parsed predefined filter metadata, or null if a regular filter was used. + */ +@InternalStreamChatApi +public data class QueryChannelsResult( + val channels: List, + val predefinedFilter: PredefinedFilter?, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt index 020cf6d082c..3080525055d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt @@ -21,17 +21,21 @@ 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.event.handler.internal.batch.BatchEvent +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateLegacyImpl import io.getstream.chat.android.client.internal.state.plugin.state.channel.thread.internal.ThreadMutableState import io.getstream.chat.android.client.internal.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.chat.android.client.internal.state.plugin.state.querythreads.internal.QueryThreadsMutableState +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.Filters import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.Thread 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.log.taggedLogger import kotlinx.coroutines.CoroutineScope @@ -68,7 +72,7 @@ public class StateRegistry @JvmOverloads constructor( private val logger by taggedLogger("Chat:StateRegistry") - private val queryChannels: ConcurrentHashMap>, QueryChannelsMutableState> = + private val queryChannels: ConcurrentHashMap = ConcurrentHashMap() private val legacyChannels: ConcurrentHashMap, ChannelStateLegacyImpl> = ConcurrentHashMap() private val channels: ConcurrentHashMap, ChannelStateImpl> = ConcurrentHashMap() @@ -84,9 +88,34 @@ public class StateRegistry @JvmOverloads constructor( * * @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]. Canonical lookup that + * works for both standard and predefined-filter queries. For predefined queries the resulting + * state starts with placeholder filter/sort that get replaced via `applyResolvedSpec` once + * the server response (or a previously persisted DB row) provides the resolved values. + * + * @param identifier The identifier of the [QueryChannelsState]. + */ + @InternalStreamChatApi + public fun queryChannels(identifier: QueryChannelsIdentifier): QueryChannelsState { + return queryChannels.getOrPut(identifier) { + val (initialFilter, initialSort) = when (identifier) { + // Use known filter + sort + is QueryChannelsIdentifier.Standard -> identifier.filter to identifier.sort + // Use temporary neutral filter + sort + is QueryChannelsIdentifier.Predefined -> Filters.neutral() to QuerySortByField() + } + QueryChannelsMutableState( + identifier = identifier, + initialFilter = initialFilter, + initialSort = initialSort, + scope = scope, + latestUsers = latestUsers, + activeLiveLocations = activeLiveLocations, + ) } } 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 2759ad342ea..6b5ce3664f2 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 @@ -20,8 +20,10 @@ import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.api.ErrorCall import io.getstream.chat.android.client.api.models.GetThreadOptions import io.getstream.chat.android.client.api.models.PinnedMessagesPagination +import io.getstream.chat.android.client.api.models.PredefinedFilter import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.api.models.QueryUsersRequest import io.getstream.chat.android.client.api.models.UpdatePollRequest @@ -42,6 +44,7 @@ import io.getstream.chat.android.client.api2.endpoint.UserApi import io.getstream.chat.android.client.api2.mapping.DomainMapping import io.getstream.chat.android.client.api2.mapping.DtoMapping import io.getstream.chat.android.client.api2.mapping.EventMapping +import io.getstream.chat.android.client.api2.mapping.toFilterDomain import io.getstream.chat.android.client.api2.model.dto.PartialUpdateUserDto import io.getstream.chat.android.client.api2.model.dto.UpstreamPushPreferenceInputDto import io.getstream.chat.android.client.api2.model.requests.AcceptInviteRequest @@ -1274,12 +1277,15 @@ constructor( } } - override fun queryChannels(query: QueryChannelsRequest): Call> { + override fun queryChannels(query: QueryChannelsRequest): Call { val request = io.getstream.chat.android.client.api2.model.requests.QueryChannelsRequest( - filter_conditions = query.filter.toMap(), + filter_conditions = if (query.predefinedFilter != null) null else query.filter.toMap(), + sort = if (query.predefinedFilter != null) null else query.sort, + predefined_filter = query.predefinedFilter, + filter_values = query.filterValues, + sort_values = query.sortValues, offset = query.offset, limit = query.limit, - sort = query.sort, message_limit = query.messageLimit, member_limit = query.memberLimit, state = query.state, @@ -1291,7 +1297,21 @@ constructor( channelApi.queryChannels( connectionId = connectionId, request = request, - ).map { response -> response.channels.map(this::flattenChannel) } + ).map { response -> + with(domainMapping) { + QueryChannelsResult( + channels = response.channels.map(this@MoshiChatApi::flattenChannel), + predefinedFilter = response.predefined_filter?.let { + val filter = it.filter.toFilterDomain() ?: return@let null + PredefinedFilter( + name = it.name, + filter = filter, + sort = it.sort.toSortDomain(), + ) + }, + ) + } + } } val isConnectionRequired = query.watch || query.presence diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 133c069aaa4..9b5b26c06c2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -125,6 +125,9 @@ import io.getstream.chat.android.models.UserId import io.getstream.chat.android.models.UserTransformer import io.getstream.chat.android.models.Vote import io.getstream.chat.android.models.VotingVisibility +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.models.querysort.QuerySorter +import io.getstream.chat.android.models.querysort.SortDirection import java.util.Date @Suppress("TooManyFunctions") @@ -903,4 +906,17 @@ internal class DomainMapping( level = PushPreferenceLevel.fromValue(chat_level), disabledUntil = disabled_until, ) + + internal fun List>?.toSortDomain(): QuerySorter? { + if (isNullOrEmpty()) return null + return fold(QuerySortByField()) { sort, sortSpecMap -> + val fieldName = sortSpecMap[QuerySorter.KEY_FIELD_NAME] as? String ?: return null + val direction = (sortSpecMap[QuerySorter.KEY_DIRECTION] as? Number)?.toInt() ?: return null + when (direction) { + SortDirection.ASC.value -> sort.asc(fieldName) + SortDirection.DESC.value -> sort.desc(fieldName) + else -> return null + } + } + } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMapping.kt new file mode 100644 index 00000000000..ad8c6b052d3 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMapping.kt @@ -0,0 +1,140 @@ +/* + * 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.mapping + +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.NeutralFilterObject + +/** + * Parses a [Map] (as deserialized by Moshi from server JSON) into a [FilterObject]. + * This is the reverse of [io.getstream.chat.android.client.parser.FilterObject.toMap]. + * + * Returns `null` if the map is `null` or cannot be parsed. + */ +internal fun Map?.toFilterDomain(): FilterObject? { + if (this == null) return null + return parseFilterMap(this) +} + +@Suppress("ComplexMethod", "SpreadOperator") +private fun parseFilterMap(map: Map): FilterObject? { + if (map.isEmpty()) return NeutralFilterObject + + if (map.size == 2 && map.containsKey(KEY_DISTINCT) && map.containsKey(KEY_MEMBERS)) { + val memberIds = (map[KEY_MEMBERS] as? Collection<*>)?.filterIsInstance() ?: return null + return Filters.distinct(memberIds) + } + + if (map.size == 1) { + val (key, value) = map.entries.first() + return parseSingleEntry(key, value) + } + + // Multi-key map: implicit AND + val filters = map.entries.mapNotNull { (key, value) -> parseSingleEntry(key, value) } + if (filters.isEmpty()) return null + if (filters.size == 1) return filters.first() + return Filters.and(*filters.toTypedArray()) +} + +@Suppress("SpreadOperator") +private fun parseSingleEntry(key: String, value: Any): FilterObject? = when (key) { + KEY_AND -> parseLogicalOperator(value) { Filters.and(*it) } + KEY_OR -> parseLogicalOperator(value) { Filters.or(*it) } + KEY_NOR -> parseLogicalOperator(value) { Filters.nor(*it) } + else -> parseFieldFilter(key, value) +} + +@Suppress("UNCHECKED_CAST") +private fun parseLogicalOperator( + value: Any, + factory: (Array) -> FilterObject, +): FilterObject? { + val list = value as? List<*> ?: return null + val filters = list.mapNotNull { item -> + (item as? Map)?.let(::parseFilterMap) + } + if (filters.isEmpty()) return null + return factory(filters.toTypedArray()) +} + +@Suppress("ComplexMethod", "DEPRECATION") +private fun parseFieldFilter(fieldName: String, value: Any): FilterObject? { + if (value !is Map<*, *>) { + return Filters.eq(fieldName, normalizeValue(value)) + } + + @Suppress("UNCHECKED_CAST") + val operatorMap = value as Map + if (operatorMap.isEmpty()) return null + val (opKey, opValue) = operatorMap.entries.first() + + return when (opKey) { + KEY_EQUALS -> Filters.eq(fieldName, normalizeValue(opValue)) + KEY_NOT_EQUALS -> Filters.ne(fieldName, normalizeValue(opValue)) + KEY_GREATER_THAN -> Filters.greaterThan(fieldName, normalizeValue(opValue)) + KEY_GREATER_THAN_OR_EQUALS -> Filters.greaterThanEquals(fieldName, normalizeValue(opValue)) + KEY_LESS_THAN -> Filters.lessThan(fieldName, normalizeValue(opValue)) + KEY_LESS_THAN_OR_EQUALS -> Filters.lessThanEquals(fieldName, normalizeValue(opValue)) + KEY_IN -> { + val values = (opValue as? Collection<*>)?.map { normalizeValue(it ?: return null) } ?: return null + Filters.`in`(fieldName, values) + } + KEY_NOT_IN -> { + val values = (opValue as? Collection<*>)?.map { normalizeValue(it ?: return null) } ?: return null + Filters.nin(fieldName, values) + } + KEY_CONTAINS -> Filters.contains(fieldName, normalizeValue(opValue)) + KEY_EXIST -> when (opValue as? Boolean) { + true -> Filters.exists(fieldName) + false -> Filters.notExists(fieldName) + null -> null + } + KEY_AUTOCOMPLETE -> { + val strValue = opValue as? String ?: return null + Filters.autocomplete(fieldName, strValue) + } + else -> null + } +} + +private fun normalizeValue(value: Any): Any = when { + value is Double && value == value.toLong().toDouble() -> { + val longVal = value.toLong() + if (longVal in Int.MIN_VALUE..Int.MAX_VALUE) longVal.toInt() else longVal + } + value is List<*> -> value.map { if (it != null) normalizeValue(it) else it } + else -> value +} + +private const val KEY_EXIST: String = "\$exists" +private const val KEY_CONTAINS: String = "\$contains" +private const val KEY_AND: String = "\$and" +private const val KEY_OR: String = "\$or" +private const val KEY_NOR: String = "\$nor" +private const val KEY_EQUALS: String = "\$eq" +private const val KEY_NOT_EQUALS: String = "\$ne" +private const val KEY_GREATER_THAN: String = "\$gt" +private const val KEY_GREATER_THAN_OR_EQUALS: String = "\$gte" +private const val KEY_LESS_THAN: String = "\$lt" +private const val KEY_LESS_THAN_OR_EQUALS: String = "\$lte" +private const val KEY_IN: String = "\$in" +private const val KEY_NOT_IN: String = "\$nin" +private const val KEY_AUTOCOMPLETE: String = "\$autocomplete" +private const val KEY_DISTINCT: String = "distinct" +private const val KEY_MEMBERS: String = "members" diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryChannelsRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryChannelsRequest.kt index cf46167aafb..ee364a9d3c5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryChannelsRequest.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryChannelsRequest.kt @@ -20,10 +20,18 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) internal data class QueryChannelsRequest( - val filter_conditions: Map<*, *>, + // Standard filter + sort query + val filter_conditions: Map<*, *>? = null, + val sort: List>? = null, + + // Predefined filters query + val predefined_filter: String? = null, + val filter_values: Map? = null, + val sort_values: Map? = null, + + // Query options val offset: Int, val limit: Int, - val sort: List>, val message_limit: Int?, val member_limit: Int?, val state: Boolean, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryChannelsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryChannelsResponse.kt index af69d6f252d..4a0140384c4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryChannelsResponse.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryChannelsResponse.kt @@ -21,4 +21,12 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) internal data class QueryChannelsResponse( val channels: List, + val predefined_filter: ParsedPredefinedFilterResponse? = null, +) + +@JsonClass(generateAdapter = true) +internal data class ParsedPredefinedFilterResponse( + val name: String, + val filter: Map, + val sort: List>? = null, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/internal/NullableMapConverter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/internal/NullableMapConverter.kt new file mode 100644 index 00000000000..467618e6eb0 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/internal/NullableMapConverter.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-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.offline.repository.database.converter.internal + +import androidx.room.TypeConverter +import com.squareup.moshi.adapter + +/** + * Type converter for nullable `Map?` columns. Unlike [ExtraDataConverter] (which + * coerces `null` to an empty map), this converter round-trips `null` faithfully so callers can + * distinguish "absent" from "empty". + * + * Apply at field level via `@field:TypeConverters(NullableMapConverter::class)` on the columns + * that need null-preserving semantics. + */ +internal class NullableMapConverter { + @OptIn(ExperimentalStdlibApi::class) + private val adapter = moshi.adapter>() + + @TypeConverter + fun stringToMap(data: String?): Map? { + if (data == null || data.isEmpty() || data == "null") return null + return adapter.fromJson(data) + } + + @TypeConverter + fun mapToString(map: Map?): String? = map?.let(adapter::toJson) +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt index 19e4ad2de97..bb48f93b1a8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt @@ -88,7 +88,7 @@ import io.getstream.chat.android.client.internal.offline.repository.domain.user. ThreadOrderEntity::class, DraftMessageEntity::class, ], - version = 103, + version = 104, exportSchema = false, ) @TypeConverters( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt index 325f6651175..2498a343b7e 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt @@ -16,11 +16,10 @@ package io.getstream.chat.android.client.internal.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,14 +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): QueryChannelsSpec? { - return queryChannelsDao.select(generateId(filter, querySort))?.let(Companion::toModel) + override suspend fun selectBy(identifier: QueryChannelsIdentifier): QueryChannelsSpec? { + return queryChannelsDao.select(generateId(identifier))?.let(Companion::toModel) } override suspend fun clear() { @@ -53,22 +46,35 @@ internal class DatabaseQueryChannelsRepository( } private companion object { - private fun generateId(filter: FilterObject, querySort: QuerySorter): String { - return "${filter.hashCode()}-${querySort.toDto().hashCode()}" + // Standard: hash of (filter, sort). Predefined: name + value-map hashes, since the + // resolved filter/sort are unknown until the server replies and we need stable identity + // across runs. + private fun generateId(identifier: QueryChannelsIdentifier): String = when (identifier) { + is QueryChannelsIdentifier.Standard -> + "${identifier.filter.hashCode()}-${identifier.sort.toDto().hashCode()}" + is QueryChannelsIdentifier.Predefined -> + "pd:${identifier.name}:${identifier.filterValues.hashCode()}:${identifier.sortValues.hashCode()}" } private fun toEntity(queryChannelsSpec: QueryChannelsSpec): QueryChannelsEntity = QueryChannelsEntity( - generateId(queryChannelsSpec.filter, queryChannelsSpec.querySort), - queryChannelsSpec.filter, - queryChannelsSpec.querySort, - queryChannelsSpec.cids.toList(), + id = generateId(queryChannelsSpec.identifier), + filter = queryChannelsSpec.filter, + querySort = queryChannelsSpec.querySort, + cids = queryChannelsSpec.cids.toList(), + predefinedFilterName = queryChannelsSpec.predefinedFilterName, + predefinedFilterValues = queryChannelsSpec.predefinedFilterValues, + predefinedSortValues = queryChannelsSpec.predefinedSortValues, ) private fun toModel(queryChannelsEntity: QueryChannelsEntity): QueryChannelsSpec = QueryChannelsSpec( - queryChannelsEntity.filter, - queryChannelsEntity.querySort, - ).apply { cids = queryChannelsEntity.cids.toSet() } + filter = queryChannelsEntity.filter, + querySort = queryChannelsEntity.querySort, + cids = queryChannelsEntity.cids.toSet(), + predefinedFilterName = queryChannelsEntity.predefinedFilterName, + predefinedFilterValues = queryChannelsEntity.predefinedFilterValues, + predefinedSortValues = queryChannelsEntity.predefinedSortValues, + ) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt index 70f9ca0b618..4621516ffe4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt @@ -18,17 +18,36 @@ package io.getstream.chat.android.client.internal.offline.repository.domain.quer import androidx.room.Entity import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.NullableMapConverter import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.querysort.QuerySorter +/** + * The entity-level [TypeConverters] annotation overrides the database-level `ExtraDataConverter` + * for `Map?` columns, so [predefinedFilterValues]/[predefinedSortValues] use + * [NullableMapConverter] and `null` round-trips faithfully (rather than being collapsed to an + * empty map). Safe at entity scope because this entity has no other `Map?` fields. + */ @Entity(tableName = QUERY_CHANNELS_ENTITY_TABLE_NAME) +@TypeConverters(NullableMapConverter::class) internal data class QueryChannelsEntity( @PrimaryKey var id: String, + /** Resolved filter. For predefined queries this is the latest server-resolved value. */ val filter: FilterObject, + /** Resolved sort. For predefined queries this is the latest server-resolved value. */ val querySort: QuerySorter, val cids: List, + /** + * Set only for predefined-filter queries; null for standard ones. Together with the value maps + * below, the predefined name forms the row's stable identity (the resolved filter/sort can + * change between runs if the server-side template changes). + */ + val predefinedFilterName: String? = null, + val predefinedFilterValues: Map? = null, + val predefinedSortValues: Map? = null, ) internal const val QUERY_CHANNELS_ENTITY_TABLE_NAME = "stream_channel_query" 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..78bd711561d --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt @@ -0,0 +1,83 @@ +/* + * 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 + +/** + * Identifies a query channels operation independently of the resolved [FilterObject] and + * [QuerySorter]. Used as the cache key in `StateRegistry`, `LogicRegistry`, and the offline DB so + * the same query consistently maps to the same logic/state instance and the same persisted row + * across runs. + * + * Two shapes are supported: + * - [Standard] for classic queries where the client knows `filter` + `querySort` upfront. + * - [Predefined] for server-side predefined filters where the actual `filter` and `querySort` are + * only learned from the response. Identity must therefore be the predefined name plus the + * interpolation values, since those are the only stable inputs available before the response. + */ +@InternalStreamChatApi +public sealed interface QueryChannelsIdentifier { + + /** + * Identity for a classic query channels request: [filter] and [sort] are known on the client + * and define the query. + */ + public data class Standard( + val filter: FilterObject, + val sort: QuerySorter, + ) : QueryChannelsIdentifier + + /** + * Identity for a server-side predefined filter: the actual filter and sort are resolved by the + * backend; identity is the predefined [name] plus the value maps used to interpolate it. + */ + public data class Predefined( + val name: String, + val filterValues: Map?, + val sortValues: Map?, + ) : QueryChannelsIdentifier +} + +/** + * Derives the [QueryChannelsIdentifier] from a [QueryChannelsRequest]. A non-null + * [QueryChannelsRequest.predefinedFilter] marks the request as a predefined-filter query and + * yields [QueryChannelsIdentifier.Predefined]; otherwise yields [QueryChannelsIdentifier.Standard] + * from the explicit `filter`/`querySort`. + */ +internal val QueryChannelsRequest.identifier: QueryChannelsIdentifier + get() = when (val name = predefinedFilter) { + null -> QueryChannelsIdentifier.Standard(filter, querySort) + else -> QueryChannelsIdentifier.Predefined(name, filterValues, sortValues) + } + +/** + * Derives the [QueryChannelsIdentifier] from a [QueryChannelsSpec]. A non-null + * [QueryChannelsSpec.predefinedFilterName] marks the spec as a predefined-filter query and yields + * [QueryChannelsIdentifier.Predefined]; otherwise yields [QueryChannelsIdentifier.Standard] from + * the resolved `filter`/`querySort`. + */ +internal val QueryChannelsSpec.identifier: QueryChannelsIdentifier + get() = when (val name = predefinedFilterName) { + null -> QueryChannelsIdentifier.Standard(filter, querySort) + else -> QueryChannelsIdentifier.Predefined(name, predefinedFilterValues, predefinedSortValues) + } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerState.kt index 3c0ee46fafc..b81f57b74bc 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerState.kt @@ -17,12 +17,13 @@ package io.getstream.chat.android.client.internal.state.plugin.listener.internal import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.QueryChannelsPaginationRequest import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.toAnyChannelPaginationRequest import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry 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.models.querysort.QuerySortByField import io.getstream.result.Result import kotlinx.coroutines.flow.MutableStateFlow @@ -40,7 +41,7 @@ import kotlinx.coroutines.flow.MutableStateFlow * @param logic [LogicRegistry] provided by the [StreamStatePluginFactory]. */ internal class QueryChannelsListenerState( - private val logicProvider: LogicRegistry, + private val logic: LogicRegistry, private val queryingChannelsFree: MutableStateFlow, ) : QueryChannelsListener { @@ -50,14 +51,26 @@ internal class QueryChannelsListenerState( override suspend fun onQueryChannelsRequest(request: QueryChannelsRequest) { queryingChannelsFree.value = false - logicProvider.queryChannels(request).run { + logic.queryChannels(request).run { setCurrentRequest(request) queryOffline(request.toPagination()) } } - override suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { - logicProvider.queryChannels(request).onQueryChannelsResult(result, request) + override suspend fun onQueryChannelsResult(result: Result, request: QueryChannelsRequest) { + val queryChannelsLogic = logic.queryChannels(request) + if (result is Result.Success) { + // Push server-resolved filter/sort into state before forwarding channels, so + // sortedChannels re-emits with the right comparator. No-op for standard queries. + result.value.predefinedFilter?.let { resolved -> + queryChannelsLogic.applyResolvedSpec( + filter = resolved.filter, + sort = resolved.sort ?: QuerySortByField(), + ) + } + } + val channels = result.map(QueryChannelsResult::channels) + queryChannelsLogic.onQueryChannelsResult(channels, request) queryingChannelsFree.value = true } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt index 9e275ca4dce..0e81c0ce7e0 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistry.kt @@ -23,6 +23,8 @@ import io.getstream.chat.android.client.api.state.StateRegistry import io.getstream.chat.android.client.channel.ChannelMessagesUpdateLogic 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.internal.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelLogicImpl import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelMessagesUpdateLogicImpl @@ -41,7 +43,6 @@ import io.getstream.chat.android.client.internal.state.plugin.state.global.inter import io.getstream.chat.android.client.internal.state.plugin.state.querychannels.internal.toMutableState 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 import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread @@ -70,17 +71,18 @@ internal class LogicRegistry internal constructor( private val useLegacyChannelLogic: Boolean, ) : 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) { + /** Returns [QueryChannelsLogic] for the given [identifier], creating it on first access. */ + 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, @@ -94,8 +96,7 @@ internal class LogicRegistry internal constructor( ) QueryChannelsLogic( - filter, - sort, + identifier, client, queryChannelsStateLogic, queryChannelsDatabaseLogic, @@ -105,7 +106,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-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt index 97f468eb246..a64fbabee7e 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.extensions.internal.applyPagination +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 @@ -26,6 +27,17 @@ import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationReq import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelConfig +/** + * Pair of the persisted [QueryChannelsSpec] and the channels associated with it. The spec is + * exposed alongside the channels so the caller can read the *resolved* `filter`/`querySort` for + * predefined-filter queries before dispatching channels into the mutable state — that way the + * sortedChannels flow re-emits with the correct comparator before the cached channels arrive. + */ +internal data class CachedQueryChannels( + val spec: QueryChannelsSpec, + val channels: List, +) + @Suppress("LongParameterList") internal class QueryChannelsDatabaseLogic( private val queryChannelsRepository: QueryChannelsRepository, @@ -39,27 +51,21 @@ internal class QueryChannelsDatabaseLogic( } /** - * Fetch channels from database. + * Fetch the cached spec and channels for the given query [identifier]. * - * @param pagination [AnyChannelPaginationRequest] - * @param queryChannelsSpec [QueryChannelsSpec] - * @return null if the spec is not found in the database, list of channels otherwise (can be empty, if the online - * query returned 0 results). + * @return null if no spec is found in the database; otherwise a [CachedQueryChannels] wrapping + * the persisted spec and the channels, paginated according to [pagination]. The channels list + * may be empty if a previous online query returned 0 results. */ internal suspend fun fetchChannelsFromCache( pagination: AnyChannelPaginationRequest, - queryChannelsSpec: QueryChannelsSpec?, - ): List? { - val cachedSpec = queryChannelsSpec?.let { - queryChannelsRepository.selectBy(it.filter, it.querySort) - } - return if (cachedSpec != null) { - // Spec is present in DB, fetch channels according to it - repositoryFacade.selectChannels(cachedSpec.cids.toList(), pagination).applyPagination(pagination) - } else { - // Spec is not present in DB, can't fetch channels - null - } + identifier: QueryChannelsIdentifier, + ): CachedQueryChannels? { + val spec = queryChannelsRepository.selectBy(identifier) ?: return null + val channels = repositoryFacade + .selectChannels(spec.cids.toList(), pagination) + .applyPagination(pagination) + return CachedQueryChannels(spec, channels) } /** diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 61361d7441b..ca88db734c8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -21,8 +21,8 @@ import io.getstream.chat.android.client.api.event.EventHandlingResult 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 import io.getstream.chat.android.models.ChannelConfig import io.getstream.chat.android.models.FilterObject @@ -37,8 +37,7 @@ private const val CHANNEL_LIMIT = 30 @Suppress("TooManyFunctions") internal class QueryChannelsLogic( - private val filter: FilterObject, - private val sort: QuerySorter, + internal val identifier: QueryChannelsIdentifier, private val client: ChatClient, private val queryChannelsStateLogic: QueryChannelsStateLogic, private val queryChannelsDatabaseLogic: QueryChannelsDatabaseLogic, @@ -55,15 +54,19 @@ internal class QueryChannelsLogic( val hasOffset = pagination.channelOffset > 0 loadingPerPage(true, hasOffset) - val offlineChannels = fetchChannelsFromCache(pagination, queryChannelsDatabaseLogic) - when { - offlineChannels == null -> { + when (val cached = queryChannelsDatabaseLogic.fetchChannelsFromCache(pagination, identifier)) { + null -> { // No cached spec found, rely on online data. Don't reset loading state here, and await online data. } else -> { - // Channels for the spec found (0 or more). Optimistic update and reset loading state. - addChannels(offlineChannels) + // For predefined queries this restores the last persisted resolved filter/sort so + // cached channels are sorted correctly before any network response. Not invoked for + // standard queries, as we already know the spec beforehand. + if (cached.spec.predefinedFilterName != null) { + applyResolvedSpec(cached.spec.filter, cached.spec.querySort) + } + addChannels(cached.channels) loadingPerPage(false, hasOffset) } } @@ -81,28 +84,16 @@ internal class QueryChannelsLogic( queryChannelsStateLogic.setCurrentRequest(request) } - internal fun filter(): FilterObject = filter - internal fun recoveryNeeded(): StateFlow { return queryChannelsStateLogic.getState().recoveryNeeded } - private suspend fun fetchChannelsFromCache( - pagination: AnyChannelPaginationRequest, - queryChannelsDatabaseLogic: QueryChannelsDatabaseLogic, - ): List? { - val queryChannelsSpec = queryChannelsStateLogic.getQuerySpecs() - - return queryChannelsDatabaseLogic.fetchChannelsFromCache(pagination, queryChannelsSpec).also { - logger.i { - val message = if (it == null) { - "no channels found in the local storage" - } else { - "${it.size} channels found in the local storage" - } - "[fetchChannelsFromCache] $message" - } - } + /** + * Forwards the resolved filter/sort to the state logic. Called by the listener with values + * from `QueryChannelsResult.predefinedFilter`. A no-op for standard queries. + */ + internal fun applyResolvedSpec(filter: FilterObject, sort: QuerySorter) { + queryChannelsStateLogic.applyResolvedSpec(filter, sort) } /** @@ -150,20 +141,35 @@ internal class QueryChannelsLogic( /** * Runs [QueryChannelsRequest] which is querying the first page. + * + * Rebuilds the request from the [identifier] so the request stays consistent with how this + * logic was registered: standard queries rebuild from filter/sort, predefined queries from + * the predefined name + value maps (filter/querySort default; backend ignores them). */ 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, - ) + val request = when (identifier) { + is QueryChannelsIdentifier.Standard -> QueryChannelsRequest( + filter = identifier.filter, + offset = INITIAL_CHANNEL_OFFSET, + limit = CHANNEL_LIMIT, + querySort = identifier.sort, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + is QueryChannelsIdentifier.Predefined -> QueryChannelsRequest( + offset = INITIAL_CHANNEL_OFFSET, + limit = CHANNEL_LIMIT, + messageLimit = messageLimit, + memberLimit = memberLimit, + predefinedFilter = identifier.name, + filterValues = identifier.filterValues, + sortValues = identifier.sortValues, + ) + } queryChannelsStateLogic.setCurrentRequest(request) @@ -221,7 +227,7 @@ internal class QueryChannelsLogic( logger.v { "[updateOnlineChannels] notUpdatedChannels.size: ${notUpdatedChannels.size}" } if (notUpdatedChannels.isNotEmpty()) { val localCids = notUpdatedChannels.values.map { it.cid } - val remoteCids = getRemoteCids(request.filter, request.limit, request.limit, existingChannels.size) + val remoteCids = getRemoteCids(request.limit, request.limit, existingChannels.size) val cidsToRemove = localCids - remoteCids.toSet() logger.v { "[updateOnlineChannels] cidsToRemove.size: ${cidsToRemove.size}" } removeChannels(cidsToRemove) @@ -238,16 +244,15 @@ internal class QueryChannelsLogic( } /** - * Returns the channel cids using specified filter. - * Might produce a several requests until it reaches [thresholdCount]. + * Returns the channel cids by re-issuing the same query (matching this logic's [identifier]) + * at advancing offsets, until [thresholdCount] is reached or the server returns a short page. + * Might produce several requests. * - * @param filter Filter to be used in [QueryChannelsRequest]. - * @param initialOffset An initial offset to be used in [QueryChannelsRequest]. - * @param step The offset change on each iteration of [QueryChannelsRequest] being fired. - * @param thresholdCount The threshold channels number where no more requests will be fired. + * For [QueryChannelsIdentifier.Predefined] we issue another predefined-filter request — we + * never substitute the server-resolved filter, since the server owns the actual filter + * definition and our cached resolved value may be stale (e.g. if the template changed). */ private suspend fun getRemoteCids( - filter: FilterObject, initialOffset: Int, step: Int, thresholdCount: Int, @@ -258,7 +263,7 @@ internal class QueryChannelsLogic( while (offset < thresholdCount) { logger.v { "[getRemoteCids] offset: $offset, limit: $step, thresholdCount: $thresholdCount" } - val channels = client.filterWithOffset(filter, offset, step) + val channels = fetchPage(offset = offset, limit = step) remoteCids.addAll(channels.map { it.cid }) logger.v { "[getRemoteCids] remoteCids.size: ${remoteCids.size}" } offset += step @@ -269,6 +274,32 @@ internal class QueryChannelsLogic( return remoteCids } + private suspend fun fetchPage(offset: Int, limit: Int): List { + val request = when (identifier) { + is QueryChannelsIdentifier.Standard -> QueryChannelsRequest( + filter = identifier.filter, + offset = offset, + limit = limit, + querySort = identifier.sort, + messageLimit = 0, + memberLimit = 0, + ) + is QueryChannelsIdentifier.Predefined -> QueryChannelsRequest( + offset = offset, + limit = limit, + messageLimit = 0, + memberLimit = 0, + predefinedFilter = identifier.name, + filterValues = identifier.filterValues, + sortValues = identifier.sortValues, + ) + } + return when (val result = client.queryChannelsInternal(request).await()) { + is Result.Success -> result.value + is Result.Failure -> emptyList() + } + } + internal suspend fun removeChannel(cid: String) = removeChannels(listOf(cid)) private suspend fun removeChannels(cidList: List) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index 8e85da50bb5..5b482ad9168 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -29,7 +29,9 @@ import io.getstream.chat.android.client.internal.state.plugin.logic.internal.Log import io.getstream.chat.android.client.internal.state.plugin.state.querychannels.internal.QueryChannelsMutableState 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.User +import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async @@ -97,6 +99,15 @@ internal class QueryChannelsStateLogic( mutableState.setCurrentRequest(request) } + /** + * Forwards the resolved [filter] and [sort] to the mutable state. Relevant for predefined + * queries (server-resolved values or DB rehydration); a no-op for standard queries since the + * values already match the constructor arguments. + */ + internal fun applyResolvedSpec(filter: FilterObject, sort: QuerySorter) { + mutableState.applyResolvedSpec(filter, sort) + } + /** * Set the end of channels. * @@ -142,7 +153,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 + @@ -197,7 +208,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) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/internal/ChatClientStateCalls.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/internal/ChatClientStateCalls.kt index 4bc2b5b1378..e69ad013670 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/internal/ChatClientStateCalls.kt @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.api.state.state 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.model.querychannels.pagination.internal.QueryChannelPaginationRequest +import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.models.Message import io.getstream.log.taggedLogger import io.getstream.result.call.Call @@ -67,7 +68,7 @@ internal class ChatClientStateCalls( chatClient.queryChannels(request).launch(scope) return deferredState .await() - .queryChannels(request.filter, request.querySort) + .queryChannels(request.identifier) .also { queryChannelsState -> queryChannelsState.chatEventHandlerFactory = chatEventHandlerFactory } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt index eb004e5fb30..210ce5b054b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.client.api.state.QueryChannelsState 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 @@ -36,25 +37,62 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +/** + * Mutable backing state for a query channels operation. Each instance corresponds to a unique + * [QueryChannelsIdentifier] (Standard or Predefined). + * + * For [QueryChannelsIdentifier.Standard], `initialFilter`/`initialSort` come from the client and + * are immutable across the lifetime of this state — [applyResolvedSpec] is effectively a no-op. + * + * For [QueryChannelsIdentifier.Predefined], `initialFilter`/`initialSort` are placeholders + * (defaults supplied by the registry) until [applyResolvedSpec] is called either with the + * server-resolved values from `QueryChannelsResult.predefinedFilter` or with values rehydrated + * from the offline DB. The internal `_sort` flow drives the sorted channel list, so re-sorting + * happens automatically once the resolved sort is applied. + */ internal class QueryChannelsMutableState( - override val filter: FilterObject, - override val sort: QuerySorter, + val identifier: QueryChannelsIdentifier, + initialFilter: FilterObject, + initialSort: QuerySorter, scope: CoroutineScope, latestUsers: StateFlow>, activeLiveLocations: StateFlow>, ) : QueryChannelsState { + private val _filter: MutableStateFlow = MutableStateFlow(initialFilter) + private val _sort: MutableStateFlow> = MutableStateFlow(initialSort) + + override val filter: FilterObject + get() = _filter.value + override val sort: QuerySorter + get() = _sort.value + 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) + /** + * In-memory cache spec for the active query. + */ + private var _querySpec: QueryChannelsSpec = when (identifier) { + is QueryChannelsIdentifier.Standard -> QueryChannelsSpec( + filter = initialFilter, + querySort = initialSort, + ) + is QueryChannelsIdentifier.Predefined -> QueryChannelsSpec( + filter = initialFilter, + querySort = initialSort, + predefinedFilterName = identifier.name, + predefinedFilterValues = identifier.filterValues, + predefinedSortValues = identifier.sortValues, + ) + } + internal val queryChannelsSpec: QueryChannelsSpec + get() = _querySpec /** * Property that exposes a map of raw channels. @@ -75,12 +113,11 @@ internal class QueryChannelsMutableState( private var _endOfChannels: MutableStateFlow? = MutableStateFlow(false) private val sortedChannels: StateFlow?> = - combine(mapChannels, latestUsers, activeLiveLocations) { channelMap, userMap, activeLocations -> + combine(mapChannels, latestUsers, activeLiveLocations, _sort) { channelMap, userMap, activeLocations, sort -> channelMap?.values ?.updateUsers(userMap) ?.updateLiveLocations(activeLocations) - }.map { channels -> - channels?.sortedWith(sort.comparator) + ?.sortedWith(sort.comparator) }.stateIn(scope, SharingStarted.Eagerly, null) private var _currentRequest: MutableStateFlow? = MutableStateFlow(null) private var _recoveryNeeded: MutableStateFlow? = MutableStateFlow(false) @@ -172,10 +209,43 @@ internal class QueryChannelsMutableState( _channelsOffset?.value = offset } + /** + * Replaces the current channel map with a new one. + * + * @param channelsMap The new map holding pairs of CID -> Channel. + */ fun setChannels(channelsMap: Map) { rawChannels = channelsMap } + /** + * Applies the resolved filter/sort to the state. Only relevant for predefined-filter queries, + * where the actual filter/sort are not known until either: + * - The server response arrives carrying `QueryChannelsResult.predefinedFilter`, or + * - The offline DB rehydrates a previously persisted resolved spec for the same identifier. + * + * No-op for [QueryChannelsIdentifier.Standard] queries — their filter/sort are fixed at + * construction time and must not be replaced. + * + * Because [QueryChannelsSpec] keeps `filter` and `querySort` as `val` for binary + * compatibility, we replace the held [_querySpec] instance instead of mutating it in place. + * `cids` and the predefined identity fields are carried over from the previous instance. + */ + fun applyResolvedSpec(filter: FilterObject, sort: QuerySorter) { + if (identifier !is QueryChannelsIdentifier.Predefined) return + _filter.value = filter + _sort.value = sort + _querySpec = _querySpec.copy(filter = filter, querySort = sort) + } + + /** + * Replaces the held [_querySpec] with a copy whose [QueryChannelsSpec.cids] are updated to + * [cids]. Required because [QueryChannelsSpec] is now fully immutable. + */ + fun setCids(cids: Set) { + _querySpec = _querySpec.copy(cids = cids) + } + fun destroy() { _channels = null _loading = null diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt index 7b4730181af..f458d68d4b7 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/sync/internal/SyncManager.kt @@ -439,7 +439,7 @@ internal class SyncManager( val failed = AtomicReference() val updatedCids = mutableSetOf() queryLogicsToRestore.forEach { queryLogic -> - logger.v { "[updateActiveQueryChannels] queryLogic.filter: ${queryLogic.filter()}" } + logger.v { "[updateActiveQueryChannels] queryLogic.identifier: ${queryLogic.identifier}" } queryLogic.queryFirstPage() .onError { logger.e { "[updateActiveQueryChannels] request failed: $it" } 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 a57e07f01a6..0bb028ea08a 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,11 +16,9 @@ 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.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 /** * Repository for queries of channels. @@ -36,12 +34,11 @@ public interface QueryChannelsRepository { public suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) /** - * Selects by a filter and query sort. + * Selects a query spec by its identifier. * - * @param filter [FilterObject] - * @param querySort [QuerySorter] + * @param identifier The query spec identifier. */ - public suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? + public suspend fun selectBy(identifier: QueryChannelsIdentifier): 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..9c73d9a52cd 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,15 @@ 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(filter: FilterObject, querySort: QuerySorter): 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/MessageDeliveredPlugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt index d0c8ffa56f6..ffc77020ccf 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.plugin import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.receipts.MessageReceiptManager import io.getstream.chat.android.models.Channel @@ -34,9 +35,9 @@ internal class MessageDeliveredPlugin( ) : Plugin { private val messageReceiptManager: MessageReceiptManager by lazy { chatClient.messageReceiptManager } - override suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { - result.onSuccessSuspend { channels -> - messageReceiptManager.markChannelsAsDelivered(channels) + override suspend fun onQueryChannelsResult(result: Result, request: QueryChannelsRequest) { + result.onSuccessSuspend { + messageReceiptManager.markChannelsAsDelivered(it.channels) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt index 6da8cde47bd..2dbee03c69f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt @@ -19,8 +19,8 @@ package io.getstream.chat.android.client.plugin.listeners import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.core.internal.InternalStreamChatApi -import io.getstream.chat.android.models.Channel import io.getstream.result.Result /** @@ -51,7 +51,7 @@ public interface QueryChannelsListener { * Runs this function on the [Result] of this [QueryChannelsRequest]. */ public suspend fun onQueryChannelsResult( - result: Result>, + result: Result, request: QueryChannelsRequest, ) { /* 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..5a4e120b2ee 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,9 +20,41 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.querysort.QuerySorter +/** + * Spec describing a query channels operation and the channel CIDs that belong to it. + * + * For predefined-filter queries the [predefinedFilterName] plus value maps form the spec's stable + * identity in the offline DB and must not change once assigned. [filter] and [querySort] are the + * *currently resolved* values for this spec instance — for predefined queries the resolved values + * are captured by replacing the held spec instance (see + * `QueryChannelsMutableState.applyResolvedSpec`). + * + * The 2-arg [constructor] and 2-arg [copy] are kept for binary compatibility with callers that + * predate the predefined-filter fields. They delegate to the primary constructor with the + * predefined fields defaulted to their empty/null values. + */ public data class QueryChannelsSpec( val filter: FilterObject, val querySort: QuerySorter, + val cids: Set = emptySet(), + val predefinedFilterName: String? = null, + val predefinedFilterValues: Map? = null, + val predefinedSortValues: Map? = null, ) { - var cids: Set = emptySet() + public constructor( + filter: FilterObject, + querySort: QuerySorter, + ) : this(filter, querySort, emptySet(), null, null, null) + + public fun copy( + filter: FilterObject = this.filter, + querySort: QuerySorter = this.querySort, + ): QueryChannelsSpec = QueryChannelsSpec( + filter = filter, + querySort = querySort, + cids = cids, + predefinedFilterName = predefinedFilterName, + predefinedFilterValues = predefinedFilterValues, + predefinedSortValues = predefinedSortValues, + ) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt index eeb5b11b2f3..a5f8d70739b 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.TypingIndicators import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.chatclient.BaseChatClientTest import io.getstream.chat.android.client.clientstate.UserState import io.getstream.chat.android.client.errors.cause.StreamChannelNotFoundException @@ -81,7 +82,7 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val request = Mother.randomQueryChannelsRequest() val channels = listOf(randomChannel()) val sut = Fixture() - .givenQueryChannelsResult(RetroSuccess(channels).toRetrofitCall()) + .givenQueryChannelsResult(RetroSuccess(QueryChannelsResult(channels, null)).toRetrofitCall()) .get() // when val result = sut.queryChannelsInternal(request).await() @@ -95,7 +96,7 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val request = Mother.randomQueryChannelsRequest() val errorCode = positiveRandomInt() val sut = Fixture() - .givenQueryChannelsResult(RetroError>(errorCode).toRetrofitCall()) + .givenQueryChannelsResult(RetroError(errorCode).toRetrofitCall()) .get() // when val result = sut.queryChannelsInternal(request).await() @@ -112,7 +113,7 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val state = randomBoolean() val response = randomChannel() val sut = Fixture() - .givenQueryChannelsResult(RetroSuccess(listOf(response)).toRetrofitCall()) + .givenQueryChannelsResult(RetroSuccess(QueryChannelsResult(listOf(response), null)).toRetrofitCall()) .get() // when val result = sut.getChannel(cid, messageLimit, memberLimit, state).await() @@ -128,7 +129,7 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val memberLimit = randomInt() val state = randomBoolean() val sut = Fixture() - .givenQueryChannelsResult(RetroSuccess(emptyList()).toRetrofitCall()) + .givenQueryChannelsResult(RetroSuccess(QueryChannelsResult(emptyList(), null)).toRetrofitCall()) .get() // when val result = sut.getChannel(cid, messageLimit, memberLimit, state).await() @@ -146,7 +147,7 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val state = randomBoolean() val errorCode = positiveRandomInt() val sut = Fixture() - .givenQueryChannelsResult(RetroError>(errorCode).toRetrofitCall()) + .givenQueryChannelsResult(RetroError(errorCode).toRetrofitCall()) .get() // when val result = sut.getChannel(cid, messageLimit, memberLimit, state).await() @@ -164,7 +165,7 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val state = randomBoolean() val response = randomChannel() val sut = Fixture() - .givenQueryChannelsResult(RetroSuccess(listOf(response)).toRetrofitCall()) + .givenQueryChannelsResult(RetroSuccess(QueryChannelsResult(listOf(response), null)).toRetrofitCall()) .get() // when val result = sut.getChannel(channelType, channelId, messageLimit, memberLimit, state).await() @@ -182,7 +183,7 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val state = randomBoolean() val errorCode = positiveRandomInt() val sut = Fixture() - .givenQueryChannelsResult(RetroError>(errorCode).toRetrofitCall()) + .givenQueryChannelsResult(RetroError(errorCode).toRetrofitCall()) .get() // when val result = sut.getChannel(channelType, channelId, messageLimit, memberLimit, state).await() @@ -1206,16 +1207,18 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val plugin = mock() val sut = Fixture() .givenPlugin(plugin) - .givenQueryChannelsResult(RetroSuccess(listOf(channel)).toRetrofitCall()) + .givenQueryChannelsResult(RetroSuccess(QueryChannelsResult(listOf(channel), null)).toRetrofitCall()) .get() // when val result = sut.queryChannels(request).await() // then verifySuccess(result, listOf(channel)) + val expectedQueryChannelsResult: Result = + Result.Success(QueryChannelsResult(listOf(channel), null)) val inOrder = Mockito.inOrder(plugin) inOrder.verify(plugin).onQueryChannelsPrecondition(request) inOrder.verify(plugin).onQueryChannelsRequest(request) - inOrder.verify(plugin).onQueryChannelsResult(result, request) + inOrder.verify(plugin).onQueryChannelsResult(expectedQueryChannelsResult, request) } @Test @@ -1226,16 +1229,18 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { val errorCode = positiveRandomInt() val sut = Fixture() .givenPlugin(plugin) - .givenQueryChannelsResult(RetroError>(errorCode).toRetrofitCall()) + .givenQueryChannelsResult(RetroError(errorCode).toRetrofitCall()) .get() // when val result = sut.queryChannels(request).await() // then verifyNetworkError(result, errorCode) + val expectedQueryChannelsResult: Result = + Result.Failure((result as Result.Failure).value) val inOrder = Mockito.inOrder(plugin) inOrder.verify(plugin).onQueryChannelsPrecondition(request) inOrder.verify(plugin).onQueryChannelsRequest(request) - inOrder.verify(plugin).onQueryChannelsResult(result, request) + inOrder.verify(plugin).onQueryChannelsResult(expectedQueryChannelsResult, request) } @Test @@ -1600,7 +1605,7 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { internal inner class Fixture { - fun givenQueryChannelsResult(result: Call>) = apply { + fun givenQueryChannelsResult(result: Call) = apply { whenever(api.queryChannels(any())).thenReturn(result) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 8fccd64aabd..06ee9008330 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -593,6 +593,9 @@ internal object Mother { querySort: QuerySorter = QuerySortByField(), messageLimit: Int? = randomInt(), memberLimit: Int? = randomInt(), + predefinedFilter: String? = null, + filterValues: Map? = null, + sortValues: Map? = null, ): QueryChannelsRequest { return QueryChannelsRequest( filter = filter, @@ -601,6 +604,9 @@ internal object Mother { querySort = querySort, messageLimit = messageLimit, memberLimit = memberLimit, + predefinedFilter = predefinedFilter, + filterValues = filterValues, + sortValues = sortValues, ) } 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 bd3674df140..8c1bfebf170 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 @@ -1530,7 +1530,9 @@ internal class MoshiChatApiTest { val hideHistory = randomBoolean() val hideHistoryBefore = randomDate() val skipPush = randomBoolean() - val result = sut.addMembers(channelType, channelId, members, systemMessage, hideHistory, hideHistoryBefore, skipPush).await() + val result = + sut.addMembers(channelType, channelId, members, systemMessage, hideHistory, hideHistoryBefore, skipPush) + .await() // then result `should be instance of` expected verify(api, times(1)).addMembers(eq(channelType), eq(channelId), any()) @@ -1892,6 +1894,48 @@ internal class MoshiChatApiTest { verify(api, times(1)).queryChannels(connectionId, expectedPayload) } + @ParameterizedTest + @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#queryChannelsWithPredefinedFilterInput") + fun testQueryChannelsWithPredefinedFilter(call: RetrofitCall, expected: KClass<*>) = + runTest { + // given + val api = mock() + whenever(api.queryChannels(any(), any())).doReturn(call) + val sut = Fixture() + .withChannelApi(api) + .get() + // when + val userId = randomString() + val connectionId = randomString() + val predefinedFilter = randomString() + val filterValues = mapOf("user_id" to randomString(), "channel_type" to randomString()) + val sortValues = mapOf("sort_field" to randomString()) + val query = Mother.randomQueryChannelsRequest( + predefinedFilter = predefinedFilter, + filterValues = filterValues, + sortValues = sortValues, + ) + sut.setConnection(userId = userId, connectionId = connectionId) + val result = sut.queryChannels(query).await() + // then + val expectedPayload = io.getstream.chat.android.client.api2.model.requests.QueryChannelsRequest( + filter_conditions = null, + sort = null, + predefined_filter = predefinedFilter, + filter_values = filterValues, + sort_values = sortValues, + offset = query.offset, + limit = query.limit, + message_limit = query.messageLimit, + member_limit = query.memberLimit, + state = query.state, + watch = query.watch, + presence = query.presence, + ) + result `should be instance of` expected + verify(api, times(1)).queryChannels(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 cf2553f44a2..2e6e66d30f6 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 @@ -439,6 +439,27 @@ internal object MoshiChatApiTestArguments { Arguments.of(RetroError(statusCode = 500).toRetrofitCall(), Result.Failure::class), ) + @JvmStatic + fun queryChannelsWithPredefinedFilterInput() = listOf( + Arguments.of( + RetroSuccess( + QueryChannelsResponse( + listOf( + ChannelResponse( + channel = Mother.randomDownstreamChannelDto(), + hidden = randomBoolean(), + membership = Mother.randomDownstreamMemberDto(), + hide_messages_before = randomDateOrNull(), + draft = randomDownstreamDraftDto(), + ), + ), + ), + ).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/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index d4d4ba07ff5..9822b3bd615 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -61,6 +61,7 @@ import io.getstream.chat.android.client.Mother.randomUnreadChannelDto import io.getstream.chat.android.client.Mother.randomUnreadCountByTeamDto import io.getstream.chat.android.client.Mother.randomUnreadDto import io.getstream.chat.android.client.Mother.randomUnreadThreadDto +import io.getstream.chat.android.client.api2.mapping.DomainMappingTest.Companion.toSortDomainArguments import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.client.extensions.internal.sortedByLastReply import io.getstream.chat.android.models.Answer @@ -68,6 +69,7 @@ import io.getstream.chat.android.models.App import io.getstream.chat.android.models.AppSettings import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.BannedUser +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelInfo import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.ChannelTransformer @@ -110,6 +112,9 @@ import io.getstream.chat.android.models.UserId import io.getstream.chat.android.models.UserTransformer import io.getstream.chat.android.models.Vote import io.getstream.chat.android.models.VotingVisibility +import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.ascByName +import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.descByName +import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomDate @@ -120,6 +125,9 @@ import io.getstream.chat.android.randomUser import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import java.util.Date @Suppress("LargeClass") @@ -952,6 +960,54 @@ internal class DomainMappingTest { assertEquals(expected, result) } + /** + * [toSortDomainArguments] + */ + @ParameterizedTest + @MethodSource("toSortDomainArguments") + fun `List of sort maps is correctly mapped to QuerySorter`( + input: List>?, + expected: QuerySorter?, + ) { + val sut = Fixture().get() + val result = with(sut) { input.toSortDomain() } + assertEquals(expected, result) + } + + companion object { + @JvmStatic + fun toSortDomainArguments() = listOf( + // null/error → null + Arguments.of(null, null), + Arguments.of(emptyList>(), null), + Arguments.of(listOf(mapOf("direction" to -1)), null), + Arguments.of(listOf(mapOf("field" to "created_at")), null), + Arguments.of(listOf(mapOf("field" to "created_at", "direction" to 0)), null), + // valid parsing + Arguments.of( + listOf(mapOf("field" to "created_at", "direction" to 1)), + ascByName("created_at"), + ), + Arguments.of( + listOf(mapOf("field" to "last_message_at", "direction" to -1)), + descByName("last_message_at"), + ), + // Double direction (Moshi edge case) + Arguments.of( + listOf(mapOf("field" to "created_at", "direction" to -1.0)), + descByName("created_at"), + ), + // multiple fields + Arguments.of( + listOf( + mapOf("field" to "created_at", "direction" to -1), + mapOf("field" to "name", "direction" to 1), + ), + descByName("created_at").ascByName("name"), + ), + ) + } + internal class Fixture { private var currentUserIdProvider: () -> UserId? = { randomString() } private var channelTransformer: ChannelTransformer = NoOpChannelTransformer diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMappingTest.kt new file mode 100644 index 00000000000..9ef642866d1 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMappingTest.kt @@ -0,0 +1,241 @@ +/* + * 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.mapping + +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.NeutralFilterObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +internal class FilterDomainMappingTest { + + /** + * [toFilterDomainArguments] + */ + @ParameterizedTest + @MethodSource("toFilterDomainArguments") + fun `Map is correctly parsed to FilterObject`( + input: Map?, + expected: FilterObject?, + ) { + val result = input.toFilterDomain() + assertEquals(expected, result) + } + + companion object Companion { + + @JvmStatic + @Suppress("LongMethod") + fun toFilterDomainArguments() = listOf( + // --- null / empty --- + Arguments.of(null, null), + Arguments.of(emptyMap(), NeutralFilterObject), + + // --- Equals (direct value, no $eq) --- + Arguments.of( + mapOf("type" to "messaging"), + Filters.eq("type", "messaging"), + ), + Arguments.of( + mapOf("frozen" to true), + Filters.eq("frozen", true), + ), + Arguments.of( + mapOf("member_count" to 5), + Filters.eq("member_count", 5), + ), + + // --- Equals (explicit $eq) --- + Arguments.of( + mapOf("type" to mapOf("\$eq" to "messaging")), + Filters.eq("type", "messaging"), + ), + Arguments.of( + mapOf("frozen" to mapOf("\$eq" to false)), + Filters.eq("frozen", false), + ), + + // --- Number normalization: whole Double → Int --- + Arguments.of( + mapOf("member_count" to 42.0), + Filters.eq("member_count", 42), + ), + Arguments.of( + mapOf("member_count" to mapOf("\$eq" to 42.0)), + Filters.eq("member_count", 42), + ), + + // --- NotEquals ($ne) --- + Arguments.of( + mapOf("type" to mapOf("\$ne" to "livestream")), + @Suppress("DEPRECATION") Filters.ne("type", "livestream"), + ), + + // --- GreaterThan ($gt) --- + Arguments.of( + mapOf("member_count" to mapOf("\$gt" to 5)), + Filters.greaterThan("member_count", 5), + ), + Arguments.of( + mapOf("member_count" to mapOf("\$gt" to 5.0)), + Filters.greaterThan("member_count", 5), + ), + + // --- GreaterThanOrEquals ($gte) --- + Arguments.of( + mapOf("member_count" to mapOf("\$gte" to 10)), + Filters.greaterThanEquals("member_count", 10), + ), + + // --- LessThan ($lt) --- + Arguments.of( + mapOf("member_count" to mapOf("\$lt" to 100)), + Filters.lessThan("member_count", 100), + ), + + // --- LessThanOrEquals ($lte) --- + Arguments.of( + mapOf("member_count" to mapOf("\$lte" to 50)), + Filters.lessThanEquals("member_count", 50), + ), + + // --- In ($in) --- + Arguments.of( + mapOf("type" to mapOf("\$in" to listOf("messaging", "livestream"))), + Filters.`in`("type", listOf("messaging", "livestream")), + ), + Arguments.of( + mapOf("status" to mapOf("\$in" to listOf(1.0, 2.0, 3.0))), + Filters.`in`("status", listOf(1, 2, 3)), + ), + + // --- NotIn ($nin) --- + Arguments.of( + mapOf("type" to mapOf("\$nin" to listOf("commerce"))), + @Suppress("DEPRECATION") Filters.nin("type", listOf("commerce")), + ), + + // --- Contains ($contains) --- + Arguments.of( + mapOf("tags" to mapOf("\$contains" to "vip")), + Filters.contains("tags", "vip"), + ), + + // --- Exists ($exists) --- + Arguments.of( + mapOf("avatar" to mapOf("\$exists" to true)), + Filters.exists("avatar"), + ), + Arguments.of( + mapOf("deleted_at" to mapOf("\$exists" to false)), + Filters.notExists("deleted_at"), + ), + + // --- Autocomplete ($autocomplete) --- + Arguments.of( + mapOf("name" to mapOf("\$autocomplete" to "joh")), + Filters.autocomplete("name", "joh"), + ), + + // --- Distinct --- + Arguments.of( + mapOf("distinct" to true, "members" to listOf("u1", "u2")), + Filters.distinct(listOf("u1", "u2")), + ), + + // --- Logical: $and --- + Arguments.of( + mapOf( + "\$and" to listOf( + mapOf("type" to "messaging"), + mapOf("member_count" to mapOf("\$gt" to 2)), + ), + ), + Filters.and( + Filters.eq("type", "messaging"), + Filters.greaterThan("member_count", 2), + ), + ), + + // --- Logical: $or --- + Arguments.of( + mapOf( + "\$or" to listOf( + mapOf("type" to "messaging"), + mapOf("type" to "livestream"), + ), + ), + Filters.or( + Filters.eq("type", "messaging"), + Filters.eq("type", "livestream"), + ), + ), + + // --- Logical: $nor --- + Arguments.of( + mapOf( + "\$nor" to listOf( + mapOf("type" to "commerce"), + ), + ), + Filters.nor( + Filters.eq("type", "commerce"), + ), + ), + + // --- Multi-field implicit AND --- + Arguments.of( + mapOf("type" to "messaging", "frozen" to false), + Filters.and( + Filters.eq("type", "messaging"), + Filters.eq("frozen", false), + ), + ), + + // --- Nested complex: $and containing $or --- + Arguments.of( + mapOf( + "\$and" to listOf( + mapOf( + "\$or" to listOf( + mapOf("type" to "messaging"), + mapOf("type" to "livestream"), + ), + ), + mapOf("members" to mapOf("\$in" to listOf("u1"))), + ), + ), + Filters.and( + Filters.or( + Filters.eq("type", "messaging"), + Filters.eq("type", "livestream"), + ), + Filters.`in`("members", listOf("u1")), + ), + ), + + // --- Date as string (preserved as-is) --- + Arguments.of( + mapOf("created_at" to mapOf("\$gt" to "2024-01-15T10:30:00.123456789Z")), + Filters.greaterThan("created_at", "2024-01-15T10:30:00.123456789Z"), + ), + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterObjectRoundTripTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterObjectRoundTripTest.kt new file mode 100644 index 00000000000..8ad602aef98 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterObjectRoundTripTest.kt @@ -0,0 +1,120 @@ +/* + * 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.mapping + +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import io.getstream.chat.android.client.parser.toMap +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.NeutralFilterObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +internal class FilterObjectRoundTripTest { + + /** + * In-memory round-trip: FilterObject -> toMap() -> toFilterDomain() + * + * [roundTripArguments] + */ + @ParameterizedTest + @MethodSource("roundTripArguments") + fun `FilterObject survives in-memory round-trip through toMap and toFilterDomain`( + original: FilterObject, + ) { + val map = original.toMap() + val restored = map.toFilterDomain() + assertEquals(original, restored) + } + + /** + * JSON round-trip: FilterObject -> toMap() -> JSON string -> Map -> toFilterDomain() + * This verifies that Moshi's Double-for-Int quirk is properly handled by normalizeValue. + * + * [roundTripArguments] + */ + @OptIn(ExperimentalStdlibApi::class) + @ParameterizedTest + @MethodSource("roundTripArguments") + fun `FilterObject survives JSON round-trip through Moshi serialization`( + original: FilterObject, + ) { + val moshi = Moshi.Builder().build() + val adapter = moshi.adapter>() + + val map = original.toMap() + val json = adapter.toJson(map) + val deserializedMap = adapter.fromJson(json) + val restored = deserializedMap.toFilterDomain() + assertEquals(original, restored) + } + + companion object { + + @JvmStatic + @Suppress("LongMethod") + fun roundTripArguments() = listOf( + Arguments.of(NeutralFilterObject), + Arguments.of(Filters.eq("type", "messaging")), + Arguments.of(Filters.eq("count", 42)), + Arguments.of(Filters.eq("frozen", false)), + Arguments.of(@Suppress("DEPRECATION") Filters.ne("type", "commerce")), + Arguments.of(Filters.greaterThan("age", 18)), + Arguments.of(Filters.greaterThanEquals("age", 18)), + Arguments.of(Filters.lessThan("age", 65)), + Arguments.of(Filters.lessThanEquals("age", 65)), + Arguments.of(Filters.`in`("status", listOf("active", "pending"))), + Arguments.of(Filters.`in`("ids", listOf(1, 2, 3))), + Arguments.of(@Suppress("DEPRECATION") Filters.nin("type", listOf("commerce"))), + Arguments.of(Filters.contains("tags", "vip")), + Arguments.of(Filters.exists("avatar")), + Arguments.of(Filters.notExists("deleted_at")), + Arguments.of(Filters.autocomplete("name", "joh")), + Arguments.of(Filters.distinct(listOf("u1", "u2"))), + Arguments.of( + Filters.and( + Filters.eq("type", "messaging"), + Filters.`in`("members", listOf("u1")), + ), + ), + Arguments.of( + Filters.or( + Filters.eq("type", "messaging"), + Filters.eq("type", "livestream"), + ), + ), + Arguments.of( + Filters.nor( + Filters.eq("type", "commerce"), + ), + ), + // deeply nested + Arguments.of( + Filters.and( + Filters.or( + Filters.eq("type", "messaging"), + Filters.eq("type", "livestream"), + ), + Filters.`in`("members", listOf("u1")), + ), + ), + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/QuerySortByFieldRoundTripTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/QuerySortByFieldRoundTripTest.kt new file mode 100644 index 00000000000..23ff476ba93 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/QuerySortByFieldRoundTripTest.kt @@ -0,0 +1,67 @@ +/* + * 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.mapping + +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.NoOpChannelTransformer +import io.getstream.chat.android.models.NoOpMessageTransformer +import io.getstream.chat.android.models.NoOpUserTransformer +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.ascByName +import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.descByName +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +internal class QuerySortByFieldRoundTripTest { + + /** + * [roundTripArguments] + */ + @ParameterizedTest + @MethodSource("roundTripArguments") + fun `QuerySortByField survives round-trip through toDto and toSortDomain`( + original: QuerySortByField, + ) { + val sut = DomainMapping( + currentUserIdProvider = { null }, + channelTransformer = NoOpChannelTransformer, + messageTransformer = NoOpMessageTransformer, + userTransformer = NoOpUserTransformer, + ) + val dto = original.toDto() + val restored = with(sut) { dto.toSortDomain() } + assertEquals(original, restored) + } + + companion object { + @JvmStatic + fun roundTripArguments() = listOf( + Arguments.of(descByName("last_message_at")), + Arguments.of(ascByName("created_at")), + Arguments.of( + descByName("last_message_at").ascByName("name"), + ), + Arguments.of( + descByName("last_message_at") + .ascByName("member_count") + .descByName("created_at"), + ), + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt index bae2fb404d3..eed604c26fe 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/Mother.kt @@ -252,7 +252,18 @@ internal fun randomQueryChannelsEntity( filter: FilterObject = NeutralFilterObject, querySort: QuerySorter = QuerySortByField(), cids: List = emptyList(), -): QueryChannelsEntity = QueryChannelsEntity(id, filter, querySort, cids) + predefinedFilterName: String? = null, + predefinedFilterValues: Map? = null, + predefinedSortValues: Map? = null, +): QueryChannelsEntity = QueryChannelsEntity( + id = id, + filter = filter, + querySort = querySort, + cids = cids, + predefinedFilterName = predefinedFilterName, + predefinedFilterValues = predefinedFilterValues, + predefinedSortValues = predefinedSortValues, +) internal fun createRoomDB(): ChatDatabase = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), ChatDatabase::class.java) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/QueryChannelsImplRepositoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/QueryChannelsImplRepositoryTest.kt index 8136822ec93..f7ef70f0c7d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/QueryChannelsImplRepositoryTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/QueryChannelsImplRepositoryTest.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.internal.offline.repository import io.getstream.chat.android.client.internal.offline.randomQueryChannelsEntity import io.getstream.chat.android.client.internal.offline.repository.domain.queryChannels.internal.DatabaseQueryChannelsRepository import io.getstream.chat.android.client.internal.offline.repository.domain.queryChannels.internal.QueryChannelsDao +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.test.randomQueryChannelsSpec import io.getstream.chat.android.models.ContainsFilterObject import io.getstream.chat.android.models.Filters @@ -31,12 +32,16 @@ import org.amshove.kluent.shouldBeInstanceOf import org.amshove.kluent.shouldBeNull import org.amshove.kluent.shouldNotBeNull import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.argThat +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -70,6 +75,10 @@ internal class QueryChannelsImplRepositoryTest { @Test fun `Given query channels spec in DB When select by id Should return not null result`() = runTest { + val identifier = QueryChannelsIdentifier.Standard( + filter = Filters.contains("cid", "cid1"), + sort = QuerySortByField(), + ) whenever(dao.select(any())) doReturn randomQueryChannelsEntity( id = "id1", filter = Filters.contains("cid", "cid1"), @@ -77,7 +86,7 @@ internal class QueryChannelsImplRepositoryTest { cids = listOf("cid1"), ) - val result = sut.selectBy(Filters.contains("cid", "cid1"), QuerySortByField()) + val result = sut.selectBy(identifier) result.shouldNotBeNull() result.filter.shouldBeInstanceOf() @@ -90,8 +99,43 @@ internal class QueryChannelsImplRepositoryTest { fun `Given no row in DB with such id When select by id Should return null`() = runTest { whenever(dao.select(any())) doReturn null - val result = sut.selectBy(NeutralFilterObject, QuerySortByField()) + val result = sut.selectBy(QueryChannelsIdentifier.Standard(NeutralFilterObject, QuerySortByField())) result.shouldBeNull() } + + @Test + fun `Two Predefined identifiers with same name but different filterValues produce different DB ids`() = runTest { + val identifierA = QueryChannelsIdentifier.Predefined("p", mapOf("a" to 1), null) + val identifierB = QueryChannelsIdentifier.Predefined("p", mapOf("a" to 2), null) + + sut.selectBy(identifierA) + sut.selectBy(identifierB) + + val captor = argumentCaptor() + verify(dao, times(2)).select(captor.capture()) + assertEquals(2, captor.allValues.size) + assertNotEquals(captor.allValues[0], captor.allValues[1]) + } + + @Test + fun `selectBy with Predefined identifier round-trips predefined fields from the entity`() = runTest { + val identifier = QueryChannelsIdentifier.Predefined("p", mapOf("a" to 1), mapOf("b" to 2)) + whenever(dao.select(any())) doReturn randomQueryChannelsEntity( + id = "id-predefined", + filter = NeutralFilterObject, + querySort = QuerySortByField(), + cids = listOf("cid1"), + predefinedFilterName = "p", + predefinedFilterValues = mapOf("a" to 1), + predefinedSortValues = mapOf("b" to 2), + ) + + val spec = sut.selectBy(identifier) + + spec.shouldNotBeNull() + assertEquals("p", spec.predefinedFilterName) + assertEquals(mapOf("a" to 1), spec.predefinedFilterValues) + assertEquals(mapOf("b" to 2), spec.predefinedSortValues) + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/NullableMapConverterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/NullableMapConverterTest.kt new file mode 100644 index 00000000000..b6c0a3b168d --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/database/converter/NullableMapConverterTest.kt @@ -0,0 +1,79 @@ +/* + * 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.offline.repository.database.converter + +import io.getstream.chat.android.client.internal.offline.repository.database.converter.internal.NullableMapConverter +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +internal class NullableMapConverterTest { + + private val converter = NullableMapConverter() + + @Test + fun `null round-trips as null`() { + val encoded = converter.mapToString(null) + val decoded = converter.stringToMap(encoded) + + assertNull(encoded) + assertNull(decoded) + } + + @Test + fun `empty map round-trips as empty map`() { + val encoded = converter.mapToString(emptyMap()) + val decoded = converter.stringToMap(encoded) + + assertNotNull(encoded) + assertEquals(emptyMap(), decoded) + } + + @Test + fun `populated map round-trips with values preserved`() { + val original = mapOf( + "string" to "value", + "int" to 42.0, + "boolean" to true, + ) + + val encoded = converter.mapToString(original) + val decoded = converter.stringToMap(encoded) + + assertEquals(original, decoded) + } + + @Test + fun `null and empty map are distinguishable after round-trip`() { + val nullDecoded = converter.stringToMap(converter.mapToString(null)) + val emptyDecoded = converter.stringToMap(converter.mapToString(emptyMap())) + + assertNull(nullDecoded) + assertEquals(emptyMap(), emptyDecoded) + } + + @Test + fun `empty string decodes to null`() { + assertNull(converter.stringToMap("")) + } + + @Test + fun `literal null string decodes to null`() { + assertNull(converter.stringToMap("null")) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/QueryChannelsEntityConverterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/QueryChannelsEntityConverterTest.kt new file mode 100644 index 00000000000..082a5f94be1 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/queryChannels/internal/QueryChannelsEntityConverterTest.kt @@ -0,0 +1,187 @@ +/* + * 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.offline.repository.domain.queryChannels.internal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.getstream.chat.android.client.internal.offline.createRoomDB +import io.getstream.chat.android.client.internal.offline.randomQueryChannelsEntity +import io.getstream.chat.android.client.internal.offline.repository.database.internal.ChatDatabase +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.models.querysort.QuerySortByField.Companion.ascByName +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Verifies the round-trip serialization of [QueryChannelsEntity] through Room. Two converter + * scopes are exercised together: + * - **Database scope** — [filter] uses `FilterObjectConverter`, [querySort] uses + * `QuerySortConverter`, [cids] uses `ListConverter`. + * - **Entity scope (overrides DB)** — [predefinedFilterValues] and [predefinedSortValues] use + * [NullableMapConverter] so `null` round-trips as `null` (rather than being collapsed to an + * empty map by the DB-level `ExtraDataConverter`). + * + * If Room ever picked the wrong converter for a column (e.g. `NullableMapConverter` for `filter`, + * or `ExtraDataConverter` for `predefinedFilterValues`), these tests would fail. + */ +@RunWith(AndroidJUnit4::class) +internal class QueryChannelsEntityConverterTest { + + private lateinit var database: ChatDatabase + private lateinit var dao: QueryChannelsDao + + @Before + fun setUp() { + database = createRoomDB() + dao = database.queryChannelsDao() + } + + @After + fun tearDown() { + database.close() + } + + @Test + fun `null predefined value maps round-trip as null`() = runTest { + val entity = randomQueryChannelsEntity( + predefinedFilterValues = null, + predefinedSortValues = null, + ) + + dao.insert(entity) + val read = dao.select(entity.id) + + assertNull(read?.predefinedFilterValues) + assertNull(read?.predefinedSortValues) + } + + @Test + fun `empty predefined value maps round-trip as empty maps`() = runTest { + val entity = randomQueryChannelsEntity( + predefinedFilterValues = emptyMap(), + predefinedSortValues = emptyMap(), + ) + + dao.insert(entity) + val read = dao.select(entity.id) + + assertEquals(emptyMap(), read?.predefinedFilterValues) + assertEquals(emptyMap(), read?.predefinedSortValues) + } + + @Test + fun `populated predefined value maps round-trip with values preserved`() = runTest { + val filterValues = mapOf("status" to "active", "score" to 7.0) + val sortValues = mapOf("direction" to "desc") + val entity = randomQueryChannelsEntity( + predefinedFilterValues = filterValues, + predefinedSortValues = sortValues, + ) + + dao.insert(entity) + val read = dao.select(entity.id) + + assertEquals(filterValues, read?.predefinedFilterValues) + assertEquals(sortValues, read?.predefinedSortValues) + } + + @Test + fun `filter and querySort round-trip via their dedicated DB-level converters`() = runTest { + // Non-trivial filter and sort. NullableMapConverter cannot serialise these types — only + // FilterObjectConverter and QuerySortConverter can — so a successful round-trip proves + // Room dispatches each column to the correct converter. + val filter = Filters.and( + Filters.eq("type", "messaging"), + Filters.contains("members", "user-1"), + ) + val querySort = QuerySortByField.descByName("last_message_at") + .ascByName("created_at") + val entity = randomQueryChannelsEntity( + filter = filter, + querySort = querySort, + cids = listOf("messaging:cid-1", "messaging:cid-2"), + ) + + dao.insert(entity) + val read = dao.select(entity.id) + + assertNotNull(read) + assertEquals(filter, read?.filter) + assertEquals(querySort, read?.querySort) + assertEquals(listOf("messaging:cid-1", "messaging:cid-2"), read?.cids) + } + + @Test + fun `standard and predefined columns round-trip together with their distinct converters`() = runTest { + // Combine a non-trivial filter/sort (handled by DB-level converters) with non-null + // predefined value maps (handled by the entity-scoped NullableMapConverter). All four + // must survive the round-trip independently — which can only happen if Room picked + // FilterObjectConverter for filter, QuerySortConverter for querySort, and + // NullableMapConverter (not ExtraDataConverter) for the two map columns. + val filter = Filters.eq("type", "team") + val querySort = QuerySortByField.ascByName("name") + val predefinedFilterValues = mapOf("status" to "active") + val predefinedSortValues = mapOf("direction" to "desc") + val entity = randomQueryChannelsEntity( + filter = filter, + querySort = querySort, + predefinedFilterName = "my-filter", + predefinedFilterValues = predefinedFilterValues, + predefinedSortValues = predefinedSortValues, + ) + + dao.insert(entity) + val read = dao.select(entity.id) + + assertNotNull(read) + assertEquals(filter, read?.filter) + assertEquals(querySort, read?.querySort) + assertEquals("my-filter", read?.predefinedFilterName) + assertEquals(predefinedFilterValues, read?.predefinedFilterValues) + assertEquals(predefinedSortValues, read?.predefinedSortValues) + } + + @Test + fun `null and empty are distinguishable after round-trip`() = runTest { + val nullEntity = randomQueryChannelsEntity( + predefinedFilterValues = null, + predefinedSortValues = null, + ) + val emptyEntity = randomQueryChannelsEntity( + predefinedFilterValues = emptyMap(), + predefinedSortValues = emptyMap(), + ) + + dao.insert(nullEntity) + dao.insert(emptyEntity) + + val nullRead = dao.select(nullEntity.id) + val emptyRead = dao.select(emptyEntity.id) + + assertNull(nullRead?.predefinedFilterValues) + assertNull(nullRead?.predefinedSortValues) + assertEquals(emptyMap(), emptyRead?.predefinedFilterValues) + assertEquals(emptyMap(), emptyRead?.predefinedSortValues) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifierTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifierTest.kt new file mode 100644 index 00000000000..7afdedd817d --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifierTest.kt @@ -0,0 +1,119 @@ +/* + * 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.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.querysort.QuerySortByField +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Test + +internal class QueryChannelsIdentifierTest { + + private val standardFilter = Filters.eq("type", "messaging") + private val standardSort = QuerySortByField.descByName("last_message_at") + + @Test + fun `request identifier returns Standard when predefinedFilter is null`() { + val request = QueryChannelsRequest(filter = standardFilter, querySort = standardSort, limit = 30) + + val identifier = request.identifier + + assertEquals(QueryChannelsIdentifier.Standard(standardFilter, standardSort), identifier) + } + + @Test + fun `request identifier returns Predefined when predefinedFilter is set`() { + val filterValues = mapOf("a" to 1) + val sortValues = mapOf("b" to 2) + val request = QueryChannelsRequest( + limit = 30, + predefinedFilter = "my-filter", + filterValues = filterValues, + sortValues = sortValues, + ) + + val identifier = request.identifier + + assertEquals( + QueryChannelsIdentifier.Predefined("my-filter", filterValues, sortValues), + identifier, + ) + } + + @Test + fun `request identifier ignores filter and querySort when predefinedFilter is set`() { + val request = QueryChannelsRequest( + // Even if a caller passes filter/querySort, they don't define identity for predefined + filter = standardFilter, + querySort = standardSort, + limit = 30, + predefinedFilter = "my-filter", + filterValues = mapOf("a" to 1), + sortValues = mapOf("b" to 2), + ) + + val identifier = request.identifier + + assertEquals( + QueryChannelsIdentifier.Predefined("my-filter", mapOf("a" to 1), mapOf("b" to 2)), + identifier, + ) + } + + @Test + fun `Predefined identifiers with same name but different filterValues are not equal`() { + val a = QueryChannelsIdentifier.Predefined("p", mapOf("a" to 1), null) + val b = QueryChannelsIdentifier.Predefined("p", mapOf("a" to 2), null) + + assertNotEquals(a, b) + } + + @Test + fun `Predefined identifiers with same name but different sortValues are not equal`() { + val a = QueryChannelsIdentifier.Predefined("p", null, mapOf("b" to 1)) + val b = QueryChannelsIdentifier.Predefined("p", null, mapOf("b" to 2)) + + assertNotEquals(a, b) + } + + @Test + fun `QueryChannelsSpec identifier returns Standard when predefinedFilterName is null`() { + val spec = QueryChannelsSpec(filter = standardFilter, querySort = standardSort) + + assertEquals(QueryChannelsIdentifier.Standard(standardFilter, standardSort), spec.identifier) + } + + @Test + fun `QueryChannelsSpec identifier returns Predefined when predefinedFilterName is set`() { + val spec = QueryChannelsSpec( + filter = standardFilter, + querySort = standardSort, + predefinedFilterName = "p", + predefinedFilterValues = mapOf("a" to 1), + predefinedSortValues = mapOf("b" to 2), + ) + + assertEquals( + QueryChannelsIdentifier.Predefined("p", mapOf("a" to 1), mapOf("b" to 2)), + spec.identifier, + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt new file mode 100644 index 00000000000..1ce7c1ff552 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt @@ -0,0 +1,155 @@ +/* + * 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.listener.internal + +import io.getstream.chat.android.client.api.models.PredefinedFilter +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult +import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal.QueryChannelsLogic +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.randomChannel +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.Assertions.assertTrue +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 + +internal class QueryChannelsListenerStateTest { + + private lateinit var queryChannelsLogic: QueryChannelsLogic + private lateinit var logicRegistry: LogicRegistry + private lateinit var queryingChannelsFree: MutableStateFlow + private lateinit var listener: QueryChannelsListenerState + + @BeforeEach + fun setUp() { + queryChannelsLogic = mock() + logicRegistry = mock { + on { queryChannels(any()) } doReturn queryChannelsLogic + } + queryingChannelsFree = MutableStateFlow(true) + listener = QueryChannelsListenerState(logicRegistry, queryingChannelsFree) + } + + @Test + fun `onQueryChannelsResult applies resolved spec when predefinedFilter is present`() = runTest { + // Given + val request = QueryChannelsRequest( + limit = 30, + predefinedFilter = "my-filter", + filterValues = mapOf("a" to 1), + ) + val resolvedFilter = Filters.eq("type", "messaging") + val resolvedSort = QuerySortByField.descByName("last_message_at") + val result = Result.Success( + QueryChannelsResult( + channels = listOf(randomChannel()), + predefinedFilter = PredefinedFilter("my-filter", resolvedFilter, resolvedSort), + ), + ) + + // When + listener.onQueryChannelsResult(result, request) + + // Then + verify(queryChannelsLogic).applyResolvedSpec(eq(resolvedFilter), eq(resolvedSort)) + } + + @Test + fun `onQueryChannelsResult applies default sort when predefinedFilter has null sort`() = runTest { + // Given + val request = QueryChannelsRequest( + limit = 30, + predefinedFilter = "my-filter", + ) + val resolvedFilter = Filters.eq("type", "messaging") + val result = Result.Success( + QueryChannelsResult( + channels = emptyList(), + predefinedFilter = PredefinedFilter("my-filter", resolvedFilter, sort = null), + ), + ) + + // When + listener.onQueryChannelsResult(result, request) + + // Then – falls back to QuerySortByField default + verify(queryChannelsLogic).applyResolvedSpec(eq(resolvedFilter), any()) + } + + @Test + fun `onQueryChannelsResult does not apply resolved spec for plain success without predefinedFilter`() = runTest { + // Given + val request = QueryChannelsRequest( + filter = Filters.eq("type", "messaging"), + querySort = QuerySortByField.descByName("last_message_at"), + limit = 30, + ) + val result = Result.Success( + QueryChannelsResult(channels = emptyList(), predefinedFilter = null), + ) + + // When + listener.onQueryChannelsResult(result, request) + + // Then + verify(queryChannelsLogic, never()).applyResolvedSpec(any(), any()) + } + + @Test + fun `onQueryChannelsResult does not apply resolved spec on failure`() = runTest { + // Given + val request = QueryChannelsRequest( + limit = 30, + predefinedFilter = "my-filter", + ) + val result: Result = Result.Failure(Error.GenericError("boom")) + + // When + listener.onQueryChannelsResult(result, request) + + // Then + verify(queryChannelsLogic, never()).applyResolvedSpec(any(), any()) + } + + @Test + fun `onQueryChannelsResult forwards channels to the logic and frees the channel-querying flag`() = runTest { + // Given + val request = QueryChannelsRequest(filter = Filters.neutral(), limit = 30) + val channels = listOf(randomChannel()) + val result = Result.Success(QueryChannelsResult(channels = channels, predefinedFilter = null)) + queryingChannelsFree.value = false + + // When + listener.onQueryChannelsResult(result, request) + + // Then + verify(queryChannelsLogic).onQueryChannelsResult(any(), eq(request)) + assertTrue(queryingChannelsFree.value) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistryTest.kt index 3853d4611f8..4935ad6c658 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistryTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/internal/LogicRegistryTest.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.api.models.QueryThreadsRequest import io.getstream.chat.android.client.api.state.StateRegistry +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelLogicImpl import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.legacy.ChannelLogicLegacyImpl import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl @@ -71,7 +72,7 @@ internal class LogicRegistryTest { private lateinit var coroutineScope: TestScope private val queryChannelsStateCache: - ConcurrentHashMap>, QueryChannelsMutableState> = ConcurrentHashMap() + ConcurrentHashMap = ConcurrentHashMap() private val threadsStateCache: ConcurrentHashMap>, QueryThreadsMutableState> = ConcurrentHashMap() private val channelStateMocks = ConcurrentHashMap, ChannelStateImpl>() @@ -97,17 +98,21 @@ internal class LogicRegistryTest { onBlocking { selectMessagesForThread(any(), any()) } doReturn emptyList() } - // Stub query channels state + // Stub query channels state. LogicRegistry now resolves state via the identifier-based + // overload, so we stub that one. For Standard identifiers we project the filter/sort back + // out as the initial values for QueryChannelsMutableState. queryChannelsStateCache.clear() - whenever(stateRegistry.queryChannels(any(), any())).thenAnswer { - val filter = it.getArgument(0) - - @Suppress("UNCHECKED_CAST") - val sort = it.getArgument>(1) - queryChannelsStateCache.getOrPut(filter to sort) { + whenever(stateRegistry.queryChannels(any())).thenAnswer { + val identifier = it.getArgument(0) + val (initialFilter, initialSort) = when (identifier) { + is QueryChannelsIdentifier.Standard -> identifier.filter to identifier.sort + is QueryChannelsIdentifier.Predefined -> Filters.neutral() to QuerySortByField() + } + queryChannelsStateCache.getOrPut(identifier) { QueryChannelsMutableState( - filter = filter, - sort = sort, + identifier = identifier, + initialFilter = initialFilter, + initialSort = initialSort, scope = coroutineScope, latestUsers = MutableStateFlow(emptyMap()), activeLiveLocations = MutableStateFlow(emptyList()), @@ -189,14 +194,16 @@ internal class LogicRegistryTest { // -- QueryChannels -- @Test - fun `queryChannels should return same instance for same filter and sort`() { + fun `queryChannels should return same instance for same identifier`() { // Given - val filter = Filters.eq("type", "messaging") - val sort = QuerySortByField.descByName("last_message_at") + val identifier = QueryChannelsIdentifier.Standard( + filter = Filters.eq("type", "messaging"), + sort = QuerySortByField.descByName("last_message_at"), + ) // When - val logic1 = logicRegistry.queryChannels(filter, sort) - val logic2 = logicRegistry.queryChannels(filter, sort) + val logic1 = logicRegistry.queryChannels(identifier) + val logic2 = logicRegistry.queryChannels(identifier) // Then Assertions.assertSame(logic1, logic2) @@ -205,13 +212,13 @@ internal class LogicRegistryTest { @Test fun `queryChannels should return different instances for different filters`() { // Given - val filter1 = Filters.eq("type", "messaging") - val filter2 = Filters.eq("type", "livestream") val sort = QuerySortByField.descByName("last_message_at") + val identifier1 = QueryChannelsIdentifier.Standard(Filters.eq("type", "messaging"), sort) + val identifier2 = QueryChannelsIdentifier.Standard(Filters.eq("type", "livestream"), sort) // When - val logic1 = logicRegistry.queryChannels(filter1, sort) - val logic2 = logicRegistry.queryChannels(filter2, sort) + val logic1 = logicRegistry.queryChannels(identifier1) + val logic2 = logicRegistry.queryChannels(identifier2) // Then Assertions.assertNotSame(logic1, logic2) @@ -221,27 +228,131 @@ internal class LogicRegistryTest { fun `queryChannels should return different instances for different sorts`() { // Given val filter = Filters.eq("type", "messaging") - val sort1 = QuerySortByField.descByName("last_message_at") - val sort2 = QuerySortByField.descByName("created_at") + val identifier1 = QueryChannelsIdentifier.Standard(filter, QuerySortByField.descByName("last_message_at")) + val identifier2 = QueryChannelsIdentifier.Standard(filter, QuerySortByField.descByName("created_at")) // When - val logic1 = logicRegistry.queryChannels(filter, sort1) - val logic2 = logicRegistry.queryChannels(filter, sort2) + val logic1 = logicRegistry.queryChannels(identifier1) + val logic2 = logicRegistry.queryChannels(identifier2) // Then Assertions.assertNotSame(logic1, logic2) } @Test - fun `queryChannels via request should return same instance as direct call with same filter and sort`() { + fun `queryChannels via request should return same instance as direct call with same identifier`() { // Given val filter = Filters.eq("type", "messaging") val sort = QuerySortByField.descByName("last_message_at") val request = QueryChannelsRequest(filter = filter, querySort = sort, limit = 30) + val identifier = QueryChannelsIdentifier.Standard(filter, sort) // When val logic1 = logicRegistry.queryChannels(request) - val logic2 = logicRegistry.queryChannels(filter, sort) + val logic2 = logicRegistry.queryChannels(identifier) + + // Then + Assertions.assertSame(logic1, logic2) + } + + @Test + fun `queryChannels should return different instances for Standard and Predefined identifiers`() { + // Given – a Predefined identifier and a Standard identifier are never the same query. + val standard = QueryChannelsIdentifier.Standard( + filter = Filters.eq("type", "messaging"), + sort = QuerySortByField.descByName("last_message_at"), + ) + val predefined = QueryChannelsIdentifier.Predefined( + name = "my-filter", + filterValues = mapOf("a" to 1), + sortValues = null, + ) + + // When + val logic1 = logicRegistry.queryChannels(standard) + val logic2 = logicRegistry.queryChannels(predefined) + + // Then + Assertions.assertNotSame(logic1, logic2) + } + + @Test + fun `queryChannels should return different instances for Predefined identifiers with different filterValues`() { + // Given + val identifier1 = QueryChannelsIdentifier.Predefined("p", mapOf("a" to 1), null) + val identifier2 = QueryChannelsIdentifier.Predefined("p", mapOf("a" to 2), null) + + // When + val logic1 = logicRegistry.queryChannels(identifier1) + val logic2 = logicRegistry.queryChannels(identifier2) + + // Then + Assertions.assertNotSame(logic1, logic2) + } + + @Test + fun `queryChannels should return same instance for same Predefined identifier`() { + // Given + val identifier = QueryChannelsIdentifier.Predefined( + name = "my-filter", + filterValues = mapOf("a" to 1), + sortValues = mapOf("b" to 2), + ) + + // When + val logic1 = logicRegistry.queryChannels(identifier) + val logic2 = logicRegistry.queryChannels(identifier) + + // Then + Assertions.assertSame(logic1, logic2) + } + + @Test + fun `queryChannels should return different instances for Predefined identifiers with different names`() { + // Given + val identifier1 = QueryChannelsIdentifier.Predefined("filter-a", null, null) + val identifier2 = QueryChannelsIdentifier.Predefined("filter-b", null, null) + + // When + val logic1 = logicRegistry.queryChannels(identifier1) + val logic2 = logicRegistry.queryChannels(identifier2) + + // Then + Assertions.assertNotSame(logic1, logic2) + } + + @Test + fun `queryChannels should return different instances for Predefined identifiers with different sortValues`() { + // Given + val identifier1 = QueryChannelsIdentifier.Predefined("p", null, mapOf("b" to 1)) + val identifier2 = QueryChannelsIdentifier.Predefined("p", null, mapOf("b" to 2)) + + // When + val logic1 = logicRegistry.queryChannels(identifier1) + val logic2 = logicRegistry.queryChannels(identifier2) + + // Then + Assertions.assertNotSame(logic1, logic2) + } + + @Test + fun `queryChannels via predefined request should return same instance as direct call with matching identifier`() { + // Given + val request = QueryChannelsRequest( + limit = 30, + predefinedFilter = "my-filter", + filterValues = mapOf("a" to 1), + sortValues = mapOf("b" to 2), + ) + val identifier = QueryChannelsIdentifier.Predefined( + name = "my-filter", + filterValues = mapOf("a" to 1), + sortValues = mapOf("b" to 2), + ) + + // When + val logic1 = logicRegistry.queryChannels(request) + val logic2 = logicRegistry.queryChannels(identifier) // Then Assertions.assertSame(logic1, logic2) @@ -259,11 +370,11 @@ internal class LogicRegistryTest { @Test fun `getActiveQueryChannelsLogic should return all created query channels`() { // Given - val filter1 = Filters.eq("type", "messaging") - val filter2 = Filters.eq("type", "livestream") val sort = QuerySortByField.descByName("last_message_at") - val logic1 = logicRegistry.queryChannels(filter1, sort) - val logic2 = logicRegistry.queryChannels(filter2, sort) + val identifier1 = QueryChannelsIdentifier.Standard(Filters.eq("type", "messaging"), sort) + val identifier2 = QueryChannelsIdentifier.Standard(Filters.eq("type", "livestream"), sort) + val logic1 = logicRegistry.queryChannels(identifier1) + val logic2 = logicRegistry.queryChannels(identifier2) // When val activeLogics = logicRegistry.getActiveQueryChannelsLogic() @@ -277,9 +388,11 @@ internal class LogicRegistryTest { @Test fun `clear should remove query channels`() { // Given - val filter = Filters.eq("type", "messaging") - val sort = QuerySortByField.descByName("last_message_at") - logicRegistry.queryChannels(filter, sort) + val identifier = QueryChannelsIdentifier.Standard( + filter = Filters.eq("type", "messaging"), + sort = QuerySortByField.descByName("last_message_at"), + ) + logicRegistry.queryChannels(identifier) Assertions.assertEquals(1, logicRegistry.getActiveQueryChannelsLogic().size) // When diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt index 0f129ef4e1c..03e8c5850ad 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt @@ -16,11 +16,11 @@ package io.getstream.chat.android.client.internal.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 import io.getstream.chat.android.client.persistance.repository.RepositoryFacade -import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.client.test.randomQueryChannelsSpec import io.getstream.chat.android.models.Channel @@ -68,42 +68,30 @@ internal class QueryChannelsDatabaseLogicTest { verify(repositoryFacade).storeStateForChannels(channels) } - @Test - fun `fetchChannelsFromCache should return null when queryChannelsSpec is null`() = runTest { - // Given - val pagination = AnyChannelPaginationRequest() - val queryChannelsSpec: QueryChannelsSpec? = null - - // When - val result = logic.fetchChannelsFromCache(pagination, queryChannelsSpec) - - // Then - assertNull(result) - } - @Test fun `fetchChannelsFromCache should return null when spec not found in database`() = runTest { // Given val filter = Filters.eq("type", "messaging") val sort = QuerySortByField.descByName("last_message_at") + val identifier = QueryChannelsIdentifier.Standard(filter, sort) val pagination = AnyChannelPaginationRequest() - val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) - whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn null + whenever(queryChannelsRepository.selectBy(identifier)) doReturn null // When - val result = logic.fetchChannelsFromCache(pagination, queryChannelsSpec) + val result = logic.fetchChannelsFromCache(pagination, identifier) // Then assertNull(result) - verify(queryChannelsRepository).selectBy(filter, sort) + verify(queryChannelsRepository).selectBy(identifier) } @Test - fun `fetchChannelsFromCache should return channels when spec found in database`() = runTest { + fun `fetchChannelsFromCache should return cached spec and channels when spec found`() = runTest { // Given val filter = Filters.eq("type", "messaging") val sort = QuerySortByField.descByName("last_message_at") + val identifier = QueryChannelsIdentifier.Standard(filter, sort) val pagination = AnyChannelPaginationRequest().apply { channelLimit = 10 channelOffset = 0 @@ -118,30 +106,31 @@ internal class QueryChannelsDatabaseLogicTest { sort = sort, cids = setOf(cid1, cid2, cid3), ) - val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) val channel1 = randomChannel(id = "channel1", type = "messaging") val channel2 = randomChannel(id = "channel2", type = "messaging") val channel3 = randomChannel(id = "channel3", type = "messaging") val expectedChannels = listOf(channel1, channel2, channel3) - whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn cachedSpec + whenever(queryChannelsRepository.selectBy(identifier)) doReturn cachedSpec whenever(repositoryFacade.selectChannels(listOf(cid1, cid2, cid3), pagination)) doReturn expectedChannels // When - val result = logic.fetchChannelsFromCache(pagination, queryChannelsSpec) + val result = logic.fetchChannelsFromCache(pagination, identifier) // Then - assertEquals(expectedChannels, result) - verify(queryChannelsRepository).selectBy(filter, sort) + assertEquals(cachedSpec, result?.spec) + assertEquals(expectedChannels, result?.channels) + verify(queryChannelsRepository).selectBy(identifier) verify(repositoryFacade).selectChannels(listOf(cid1, cid2, cid3), pagination) } @Test - fun `fetchChannelsFromCache should return empty list when spec found but no channels`() = runTest { + fun `fetchChannelsFromCache should return empty channels list when spec found but no cids`() = runTest { // Given val filter = Filters.eq("type", "messaging") val sort = QuerySortByField.descByName("last_message_at") + val identifier = QueryChannelsIdentifier.Standard(filter, sort) val pagination = AnyChannelPaginationRequest() val cachedSpec = randomQueryChannelsSpec( @@ -149,17 +138,17 @@ internal class QueryChannelsDatabaseLogicTest { sort = sort, cids = emptySet(), ) - val queryChannelsSpec = randomQueryChannelsSpec(filter = filter, sort = sort) - whenever(queryChannelsRepository.selectBy(filter, sort)) doReturn cachedSpec + whenever(queryChannelsRepository.selectBy(identifier)) doReturn cachedSpec whenever(repositoryFacade.selectChannels(emptyList(), pagination)) doReturn emptyList() // When - val result = logic.fetchChannelsFromCache(pagination, queryChannelsSpec) + val result = logic.fetchChannelsFromCache(pagination, identifier) // Then - assertEquals(emptyList(), result) - verify(queryChannelsRepository).selectBy(filter, sort) + assertEquals(cachedSpec, result?.spec) + assertEquals(emptyList(), result?.channels) + verify(queryChannelsRepository).selectBy(identifier) verify(repositoryFacade).selectChannels(emptyList(), pagination) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index a4eab10a56e..b8670d7ec98 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -20,6 +20,7 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.event.EventHandlingResult import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.state.QueryChannelsState +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 @@ -51,6 +52,7 @@ internal class QueryChannelsLogicTest { private lateinit var filter: FilterObject private lateinit var sort: QuerySortByField + private lateinit var identifier: QueryChannelsIdentifier.Standard private lateinit var client: ChatClient private lateinit var queryChannelsStateLogic: QueryChannelsStateLogic private lateinit var queryChannelsDatabaseLogic: QueryChannelsDatabaseLogic @@ -62,6 +64,7 @@ internal class QueryChannelsLogicTest { fun setUp() { filter = Filters.eq("type", "messaging") sort = QuerySortByField.descByName("last_message_at") + identifier = QueryChannelsIdentifier.Standard(filter, sort) client = mock() queryChannelsStateLogic = mock() queryChannelsDatabaseLogic = mock() @@ -74,8 +77,7 @@ internal class QueryChannelsLogicTest { whenever(queryChannelsStateLogic.getQuerySpecs()) doReturn queryChannelsSpec logic = QueryChannelsLogic( - filter = filter, - sort = sort, + identifier = identifier, client = client, queryChannelsStateLogic = queryChannelsStateLogic, queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, @@ -133,7 +135,7 @@ internal class QueryChannelsLogicTest { } @Test - fun `queryOffline should fetch channels from cache with correct parameters`() = runTest { + fun `queryOffline should fetch channels from cache with the identifier`() = runTest { // Given val pagination = AnyChannelPaginationRequest().apply { channelOffset = 0 @@ -148,7 +150,7 @@ internal class QueryChannelsLogicTest { // Then verify(queryChannelsDatabaseLogic).fetchChannelsFromCache( eq(pagination), - eq(queryChannelsSpec), + eq(identifier), ) } @@ -171,8 +173,8 @@ internal class QueryChannelsLogicTest { } @Test - fun `queryOffline should add channels and reset loading first page state when cached channels found`() = runTest { - // Given + fun `queryOffline should add channels and reset loading on Standard cache hit without applying spec`() = runTest { + // Given – Standard cache hit; the spec is already known so applyResolvedSpec is skipped. val pagination = AnyChannelPaginationRequest().apply { channelOffset = 0 } @@ -181,28 +183,66 @@ internal class QueryChannelsLogicTest { randomChannel(id = "channel2", type = "messaging"), randomChannel(id = "channel3", type = "messaging"), ) + val cached = CachedQueryChannels(spec = queryChannelsSpec, channels = cachedChannels) whenever(queryChannelsStateLogic.isLoading()) doReturn false - whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cachedChannels + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cached // When logic.queryOffline(pagination) // Then verify(queryChannelsStateLogic).setLoadingFirstPage(true) + verify(queryChannelsStateLogic, never()).applyResolvedSpec(any(), any()) verify(queryChannelsStateLogic).addChannelsState(cachedChannels) verify(queryChannelsStateLogic).setLoadingFirstPage(false) verify(queryChannelsStateLogic, never()).setLoadingMore(any()) } @Test - fun `queryOffline should add empty list and reset loading when cached empty list is found`() = runTest { + fun `queryOffline should apply resolved spec from cached predefined spec before adding channels`() = runTest { + // Given – a Predefined-identifier logic with a predefined cached spec + val predefinedIdentifier = QueryChannelsIdentifier.Predefined( + name = "my-filter", + filterValues = mapOf("a" to 1), + sortValues = null, + ) + val predefinedLogic = QueryChannelsLogic( + identifier = predefinedIdentifier, + client = client, + queryChannelsStateLogic = queryChannelsStateLogic, + queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, + ) + val resolvedFilter = Filters.eq("type", "messaging") + val resolvedSort = QuerySortByField.descByName("last_message_at") + val predefinedSpec = QueryChannelsSpec( + filter = resolvedFilter, + querySort = resolvedSort, + predefinedFilterName = "my-filter", + predefinedFilterValues = mapOf("a" to 1), + predefinedSortValues = null, + ) + val cached = CachedQueryChannels(spec = predefinedSpec, channels = listOf(randomChannel())) + val pagination = AnyChannelPaginationRequest().apply { channelOffset = 0 } + whenever(queryChannelsStateLogic.isLoading()) doReturn false + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cached + + // When + predefinedLogic.queryOffline(pagination) + + // Then + verify(queryChannelsStateLogic).applyResolvedSpec(eq(resolvedFilter), eq(resolvedSort)) + verify(queryChannelsStateLogic).addChannelsState(cached.channels) + } + + @Test + fun `queryOffline should add empty list and reset loading when cache hit returns no channels`() = runTest { // Given val pagination = AnyChannelPaginationRequest().apply { channelOffset = 0 } - val cachedChannels = emptyList() + val cached = CachedQueryChannels(spec = queryChannelsSpec, channels = emptyList()) whenever(queryChannelsStateLogic.isLoading()) doReturn false - whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cachedChannels + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cached // When logic.queryOffline(pagination) @@ -220,8 +260,9 @@ internal class QueryChannelsLogicTest { channelOffset = 0 } val cachedChannels = listOf(randomChannel()) + val cached = CachedQueryChannels(spec = queryChannelsSpec, channels = cachedChannels) whenever(queryChannelsStateLogic.isLoading()) doReturn false - whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cachedChannels + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cached whenever(queryChannelsStateLogic.getQuerySpecs()) doReturn queryChannelsSpec // When @@ -285,6 +326,39 @@ internal class QueryChannelsLogicTest { verify(client).queryChannelsInternal(expectedRequest) } + @Test + fun `queryFirstPage rebuilds a predefined-filter request from a Predefined identifier`() = runTest { + // Given + val predefinedIdentifier = QueryChannelsIdentifier.Predefined( + name = "my-predefined", + filterValues = mapOf("a" to 1), + sortValues = mapOf("b" to 2), + ) + val predefinedLogic = QueryChannelsLogic( + identifier = predefinedIdentifier, + client = client, + queryChannelsStateLogic = queryChannelsStateLogic, + queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, + ) + whenever(client.queryChannelsInternal(any())) + .thenReturn(emptyList().asCall()) + + // When + predefinedLogic.queryFirstPage() + + // Then + val expectedRequest = QueryChannelsRequest( + offset = 0, + limit = 30, + messageLimit = null, + memberLimit = null, + predefinedFilter = "my-predefined", + filterValues = mapOf("a" to 1), + sortValues = mapOf("b" to 2), + ) + verify(client).queryChannelsInternal(expectedRequest) + } + // endregion // region parseChatEventResults diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt index dd60917ce9c..45ad60ed1fd 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt @@ -30,7 +30,6 @@ import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomString 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 @@ -51,11 +50,11 @@ internal class QueryChannelsStateLogicTest { private val id = randomString() private val testCid = (type to id).toCid() - private val queryChannelsSpec = - QueryChannelsSpec(Filters.neutral(), QuerySortByField.descByName("")) - .apply { - cids = setOf(testCid) - } + private val queryChannelsSpec = QueryChannelsSpec( + filter = Filters.neutral(), + querySort = QuerySortByField.descByName(""), + cids = setOf(testCid), + ) private val mutableState: QueryChannelsMutableState = mock { on(it.rawChannels) doReturn emptyMap() @@ -132,7 +131,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 }) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt new file mode 100644 index 00000000000..04fa111e9b7 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt @@ -0,0 +1,138 @@ +/* + * 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.state.querychannels.internal + +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +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.randomChannel +import io.getstream.chat.android.test.TestCoroutineExtension +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +internal class QueryChannelsMutableStateTest { + + companion object { + @JvmField + @RegisterExtension + val testCoroutines = TestCoroutineExtension() + } + + private val initialFilter = Filters.eq("type", "messaging") + private val initialSort = QuerySortByField.descByName("last_message_at") + + private fun newState( + identifier: QueryChannelsIdentifier = QueryChannelsIdentifier.Standard(initialFilter, initialSort), + ) = QueryChannelsMutableState( + identifier = identifier, + initialFilter = initialFilter, + initialSort = initialSort, + scope = testCoroutines.scope, + latestUsers = MutableStateFlow(emptyMap()), + activeLiveLocations = MutableStateFlow(emptyList()), + ) + + private val predefinedIdentifier = QueryChannelsIdentifier.Predefined( + name = "predefined", + filterValues = null, + sortValues = null, + ) + + @Test + fun `applyResolvedSpec updates filter and sort accessors`() { + val state = newState(identifier = predefinedIdentifier) + val newFilter = Filters.eq("type", "team") + val newSort = QuerySortByField.ascByName("name") + + state.applyResolvedSpec(newFilter, newSort) + + assertEquals(newFilter, state.filter) + assertEquals(newSort, state.sort) + } + + @Test + fun `applyResolvedSpec updates the in-memory queryChannelsSpec`() { + val state = newState(identifier = predefinedIdentifier) + val newFilter = Filters.eq("type", "team") + val newSort = QuerySortByField.ascByName("name") + + state.applyResolvedSpec(newFilter, newSort) + + assertEquals(newFilter, state.queryChannelsSpec.filter) + assertEquals(newSort, state.queryChannelsSpec.querySort) + } + + @Test + fun `applyResolvedSpec re-sorts the channels flow with the new comparator`() { + // Given a predefined-identifier state seeded with channels sorted descending by name. + val descByName = QuerySortByField.descByName("name") + val descState = QueryChannelsMutableState( + identifier = predefinedIdentifier, + initialFilter = initialFilter, + initialSort = descByName, + scope = testCoroutines.scope, + latestUsers = MutableStateFlow(emptyMap()), + activeLiveLocations = MutableStateFlow(emptyList()), + ) + val a = randomChannel(id = "a", type = "messaging", name = "alpha") + val b = randomChannel(id = "b", type = "messaging", name = "bravo") + val c = randomChannel(id = "c", type = "messaging", name = "charlie") + descState.setChannels(mapOf(a.cid to a, b.cid to b, c.cid to c)) + + val sortedDesc = descState.channels.value!!.map { it.name } + assertEquals(listOf("charlie", "bravo", "alpha"), sortedDesc) + + // When the resolved sort flips to ascending, channels re-emit in the new order. + val ascByName = QuerySortByField.ascByName("name") + descState.applyResolvedSpec(initialFilter, ascByName) + + val sortedAsc = descState.channels.value!!.map { it.name } + assertEquals(listOf("alpha", "bravo", "charlie"), sortedAsc) + } + + @Test + fun `applyResolvedSpec is a no-op for Standard identifier`() { + val state = newState(identifier = QueryChannelsIdentifier.Standard(initialFilter, initialSort)) + val newFilter = Filters.eq("type", "team") + val newSort = QuerySortByField.ascByName("name") + + state.applyResolvedSpec(newFilter, newSort) + + assertEquals(initialFilter, state.filter) + assertEquals(initialSort, state.sort) + assertEquals(initialFilter, state.queryChannelsSpec.filter) + assertEquals(initialSort, state.queryChannelsSpec.querySort) + } + + @Test + fun `predefined identifier wires predefined fields into the spec`() { + val identifier = QueryChannelsIdentifier.Predefined( + name = "p", + filterValues = mapOf("a" to 1), + sortValues = mapOf("b" to 2), + ) + + val state = newState(identifier = identifier) + + assertEquals("p", state.queryChannelsSpec.predefinedFilterName) + assertEquals(mapOf("a" to 1), state.queryChannelsSpec.predefinedFilterValues) + assertEquals(mapOf("b" to 2), state.queryChannelsSpec.predefinedSortValues) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt index 661d5e832cc..94da899a056 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.plugin +import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.receipts.MessageReceiptManager import io.getstream.chat.android.models.Channel import io.getstream.chat.android.randomChannel @@ -38,7 +39,7 @@ internal class MessageDeliveredPluginTest { val fixture = Fixture() val sut = fixture.get() - sut.onQueryChannelsResult(result = Result.Success(channels), request = mock()) + sut.onQueryChannelsResult(result = Result.Success(QueryChannelsResult(channels, null)), request = mock()) fixture.verifyMarkChannelsAsDeliveredCalled(channels = channels) } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt index 44475d2c963..a1d57aaae96 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt @@ -61,7 +61,6 @@ import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.api.state.globalStateFlow import io.getstream.chat.android.compose.sample.ChatHelper import io.getstream.chat.android.compose.sample.R -import io.getstream.chat.android.compose.sample.feature.channel.ChannelConstants.CHANNEL_ARG_DRAFT import io.getstream.chat.android.compose.sample.feature.channel.add.AddChannelActivity import io.getstream.chat.android.compose.sample.feature.channel.add.group.AddGroupChannelActivity import io.getstream.chat.android.compose.sample.feature.channel.isGroupChannel @@ -95,10 +94,8 @@ import io.getstream.chat.android.compose.viewmodel.mentions.MentionListViewModel import io.getstream.chat.android.compose.viewmodel.mentions.MentionListViewModelFactory import io.getstream.chat.android.compose.viewmodel.threads.ThreadsViewModelFactory import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread -import io.getstream.chat.android.models.querysort.QuerySortByField import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope @@ -109,16 +106,32 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) class ChannelsActivity : ComponentActivity() { + /** + * The provided predefined filter has the following specs: + * + * **Filter:** + * ``` + * Filters.and( + * Filters.eq("type", "messaging"), + * Filters.`in`("members", listOf(currentUserId)), + * Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), + * ) + * ``` + * + * **Sort:** + * ``` + * QuerySortByField.descByName("last_updated") + * ``` + */ private val channelsViewModelFactory by lazy { val chatClient = ChatClient.instance() val currentUserId = chatClient.getCurrentUser()?.id ?: "" ChannelListViewModelFactory( chatClient = chatClient, - querySort = QuerySortByField.descByName("last_updated"), - filters = Filters.and( - Filters.eq("type", "messaging"), - Filters.`in`("members", listOf(currentUserId)), - Filters.or(Filters.notExists(CHANNEL_ARG_DRAFT), Filters.eq(CHANNEL_ARG_DRAFT, false)), + predefinedFilterName = "android_sample_filter", + filterValues = mapOf( + "channel_type" to "messaging", + "user_id" to currentUserId, ), chatEventHandlerFactory = CustomChatEventHandlerFactory(), ) 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 7f7824683fa..1dbb08fe5f7 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -6581,6 +6581,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/client/api/event/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/client/api/event/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;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/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;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/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 blockUser (Ljava/lang/String;)V public final fun confirmPendingAction ()V @@ -6618,8 +6620,18 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public static final field $stable I public fun ()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/client/api/event/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/client/api/event/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/client/api/event/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/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; public fun create (Ljava/lang/Class;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; public fun create (Lkotlin/reflect/KClass;Landroidx/lifecycle/viewmodel/CreationExtras;)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 85145e37642..645344b0330 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 @@ -78,9 +78,6 @@ import kotlin.coroutines.cancellation.CancellationException * [Channel] items in a list. * * @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. @@ -93,21 +90,94 @@ import kotlin.coroutines.cancellation.CancellationException * @param globalState A flow emitting the current [GlobalState]. */ @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 draftMessagesEnabled: Boolean = true, - private val messageSearchSort: QuerySorter? = null, - private val globalState: Flow = chatClient.globalStateFlow, + private val mode: QueryMode, + private val channelLimit: Int, + private val memberLimit: Int?, + private val messageLimit: Int?, + private val chatEventHandlerFactory: ChatEventHandlerFactory, + searchDebounceMs: Long, + private val draftMessagesEnabled: Boolean, + private val messageSearchSort: QuerySorter?, + private val globalState: Flow, ) : ViewModel() { + /** + * Creates a view model that queries channels by an explicit filter and sort. + * + * @param initialSort The initial sort used for [Channel]s. Can be changed at runtime via [setQuerySort]. + * @param initialFilters The data filter. Can be changed at runtime via [setFilters]. When `null`, + * a default filter scoped to messaging channels the current user is a member of is used. + */ + 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, + draftMessagesEnabled: Boolean = true, + messageSearchSort: QuerySorter? = null, + globalState: Flow = chatClient.globalStateFlow, + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilter = initialFilters, initialSort = initialSort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = messageSearchSort, + globalState = globalState, + ) + + /** + * Creates a view model that queries channels using a predefined filter resolved by the server. + * + * The filter and sort are identified by [predefinedFilterName] and resolved server-side; + * [filterValues] and [sortValues] interpolate into the predefined template. [setFilters] and + * [setQuerySort] do not affect a view model created this way. Channel search still narrows the + * displayed list to the search predicate. + * + * @param predefinedFilterName The name of the predefined filter registered on the backend. + * @param filterValues Optional values interpolated into the predefined filter template. + * @param sortValues Optional values interpolated into the predefined sort template. + */ + public constructor( + chatClient: ChatClient, + predefinedFilterName: String, + filterValues: Map? = null, + sortValues: Map? = null, + channelLimit: Int = DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, + draftMessagesEnabled: Boolean = true, + messageSearchSort: QuerySorter? = null, + globalState: Flow = chatClient.globalStateFlow, + ) : this( + chatClient = chatClient, + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = filterValues, + sortValues = sortValues, + ), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = searchDebounceMs, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = messageSearchSort, + globalState = globalState, + ) + private val logger by taggedLogger("Chat:ChannelListVM") /** @@ -131,14 +201,26 @@ public class ChannelListViewModel( private val queryChannelDebouncer = Debouncer(searchDebounceMs, chListScope) /** - * State flow that keeps the value of the current [FilterObject] for channels. + * State flow that keeps the value of the current [FilterObject] for channels. Only meaningful in + * [QueryMode.Standard]; remains `null` in [QueryMode.Predefined] (the server owns the filter). */ - private val filterFlow: MutableStateFlow = MutableStateFlow(initialFilters) + private val filterFlow: MutableStateFlow = MutableStateFlow( + when (mode) { + is QueryMode.Standard -> mode.initialFilter + is QueryMode.Predefined -> null + }, + ) /** - * State flow that keeps the value of the current [QuerySorter] for channels. + * State flow that keeps the value of the current [QuerySorter] for channels. Only meaningful in + * [QueryMode.Standard]; in [QueryMode.Predefined] it carries an inert default (the server owns the sort). */ - private val querySortFlow: MutableStateFlow> = MutableStateFlow(initialSort) + private val querySortFlow: MutableStateFlow> = MutableStateFlow( + when (mode) { + is QueryMode.Standard -> mode.initialSort + is QueryMode.Predefined -> QuerySortByField() + }, + ) /** * The currently active query configuration, stored in a [MutableStateFlow]. It's created using @@ -251,7 +333,7 @@ public class ChannelListViewModel( * 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() @@ -269,27 +351,26 @@ public class ChannelListViewModel( */ 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, - 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) - } + val activeQuery: Flow = when (mode) { + is QueryMode.Standard -> combine(_searchQuery, queryConfigFlow, refreshFlow) { query, _, _ -> query } + is QueryMode.Predefined -> combine(_searchQuery, refreshFlow) { query, _ -> query } + } + activeQuery.collectLatest { query -> + logger.i { "[observeInit] query: $query" } + when (query) { + is SearchQuery.Empty, + is SearchQuery.Channels, + -> { + searchScope.coroutineContext.cancelChildren() + observeQueryChannels(query.query) + } + is SearchQuery.Messages -> { + chListScope.coroutineContext.cancelChildren() + handleSearchQuery(query.query) + observeSearchMessages(query.query) } } + } } private suspend fun observeSearchMessages(query: String) = runCatching { @@ -412,15 +493,12 @@ public class ChannelListViewModel( } @Suppress("LongMethod") - private fun observeQueryChannels(config: QueryConfig) = runCatching { + private fun observeQueryChannels(searchQuery: String) = runCatching { queryChannelDebouncer.submitSuspendable { - val queryChannelsRequest = QueryChannelsRequest( - filter = config.filters, - querySort = config.querySort, - limit = channelLimit, - messageLimit = messageLimit, - memberLimit = memberLimit, - ) + val queryChannelsRequest = buildQueryChannelsRequest(searchQuery) ?: run { + logger.v { "[observeQueryChannels] rejected (filter not yet initialized)" } + return@submitSuspendable + } logger.d { "[observeQueryChannels] request: $queryChannelsRequest" } queryChannelsState = chatClient.queryChannelsAsState( request = queryChannelsRequest, @@ -477,6 +555,45 @@ public class ChannelListViewModel( } } + /** + * Builds a [QueryChannelsRequest] for the current [mode] and [searchQuery]. Returns `null` in Standard + * mode when the filter has not yet been resolved (e.g. before [buildDefaultFilter] completes); in that + * case the caller should skip the request — the next emission of [filterFlow] will re-trigger. + * + * In Predefined mode with an active channel search, falls back to a Standard request whose filter is + * just [searchChannelFilter] (the predefined filter is server-owned and cannot be combined locally). + */ + private fun buildQueryChannelsRequest(searchQuery: String): QueryChannelsRequest? = when (val mode = 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.Predefined -> if (searchQuery.length >= MIN_CHANNEL_SEARCH_QUERY_LENGTH) { + QueryChannelsRequest( + filter = optimizedChannelSearchFilter(searchQuery), + limit = channelLimit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + } else { + QueryChannelsRequest( + filter = Filters.neutral(), + limit = channelLimit, + messageLimit = messageLimit, + memberLimit = memberLimit, + predefinedFilter = mode.name, + filterValues = mode.filterValues, + sortValues = mode.sortValues, + ) + } + } + /** * Creates a filter that is used to query channels. * @@ -506,6 +623,11 @@ public class ChannelListViewModel( } } + @Deprecated( + message = "Avoid using this search query as `member.user.name` is an expensive operation. " + + "In the future, we should migrate to use optimizedChannelSearchFilter.", + replaceWith = ReplaceWith("optimizedChannelSearchFilter(searchQuery)"), + ) private fun searchChannelFilter(searchQuery: String): FilterObject { return Filters.or( Filters.autocomplete("member.user.name", searchQuery), @@ -513,6 +635,12 @@ public class ChannelListViewModel( ) } + private fun optimizedChannelSearchFilter(text: String) = + Filters.and( + Filters.autocomplete("name", text), + Filters.`in`("members", user.value?.id.orEmpty()), + ) + /** * Refreshes either channels or search results. */ @@ -542,13 +670,19 @@ public class ChannelListViewModel( /** * Allows for the change of filters used for channel queries. * - * Use this if you need to support runtime filter changes, through custom filters UI. + * Use this if you need to support runtime filter changes, through custom filters UI. The applied + * filter overrides the `initialFilters` set through the constructor. * - * Warning: The filter that's applied will override the `initialFilters` set through the constructor. + * Has no effect on view models constructed for a predefined-filter query — the predefined identity + * is fixed at construction. A warning is logged in that case. * * @param newFilters The new filters to be used as a baseline for filtering channels. */ public fun setFilters(newFilters: FilterObject) { + if (mode is QueryMode.Predefined) { + logger.w { "[setFilters] ignored — view model uses predefined filter '${mode.name}'" } + return + } this.filterFlow.tryEmit(value = newFilters) } @@ -556,8 +690,15 @@ 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. + * + * Has no effect on view models constructed for a predefined-filter query — the sort is resolved by + * the server. A warning is logged in that case. */ public fun setQuerySort(querySort: QuerySorter) { + if (mode is QueryMode.Predefined) { + logger.w { "[setQuerySort] ignored — view model uses predefined filter '${mode.name}'" } + return + } this.querySortFlow.tryEmit(value = querySort) } @@ -582,11 +723,6 @@ 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)" } - return - } val currentQuery = queryChannelsState.value?.nextPageRequest?.value if (currentQuery == null) { logger.v { "[loadMoreQueryChannels] rejected (no current query)" } @@ -600,10 +736,19 @@ 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, + ) + } + is QueryMode.Predefined -> currentQuery + } if (lastNextQuery == nextQuery) { logger.v { "[loadMoreQueryChannels] rejected (same query)" } return @@ -829,7 +974,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. @@ -837,6 +982,19 @@ public class ChannelListViewModel( private const val MIN_CHANNEL_SEARCH_QUERY_LENGTH = 3 } + internal sealed interface QueryMode { + data class Standard( + val initialFilter: FilterObject?, + val initialSort: QuerySorter, + ) : QueryMode + + data class Predefined( + val name: String, + val filterValues: Map?, + val sortValues: Map?, + ) : QueryMode + } + private data class SearchMessageState( val query: String = "", val canLoadMore: Boolean = true, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt index 2c2f702e2e8..11a12998783 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelFactory.kt @@ -21,6 +21,8 @@ import androidx.lifecycle.ViewModelProvider import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.event.ChatEventHandler import io.getstream.chat.android.client.api.event.ChatEventHandlerFactory +import io.getstream.chat.android.client.api.state.globalStateFlow +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 @@ -32,8 +34,6 @@ import io.getstream.chat.android.models.querysort.QuerySorter * 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. @@ -43,18 +43,81 @@ import io.getstream.chat.android.models.querysort.QuerySorter * @param draftMessagesEnabled If the draft message feature is enabled. * @param messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. */ -public class ChannelListViewModelFactory( - 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 draftMessagesEnabled: Boolean = true, - private val messageSearchSort: QuerySorter? = null, +@Suppress("LongParameterList") +public class ChannelListViewModelFactory 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 draftMessagesEnabled: Boolean, + private val messageSearchSort: QuerySorter?, ) : ViewModelProvider.Factory { + /** + * Builds a factory for a [ChannelListViewModel] that queries channels by an explicit filter and sort. + * + * @param querySort The sorting order for channels. + * @param filters The base filters used to filter out channels. When `null`, a default filter scoped + * to messaging channels the current user is a member of 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), + draftMessagesEnabled: Boolean = true, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilter = filters, initialSort = querySort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = messageSearchSort, + ) + + /** + * Builds a factory for a [ChannelListViewModel] that queries channels using a predefined filter + * resolved by the server. + * + * @param predefinedFilterName The name of the predefined filter registered on the backend. + * @param filterValues Optional values interpolated into the predefined filter template. + * @param sortValues Optional values interpolated into the predefined sort template. + */ + public constructor( + chatClient: ChatClient = ChatClient.instance(), + predefinedFilterName: String, + filterValues: Map? = null, + sortValues: Map? = null, + channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + draftMessagesEnabled: Boolean = true, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = filterValues, + sortValues = sortValues, + ), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + draftMessagesEnabled = draftMessagesEnabled, + messageSearchSort = messageSearchSort, + ) + /** * Create a new instance of [ChannelListViewModel] class. */ @@ -65,14 +128,15 @@ public class ChannelListViewModelFactory( @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, draftMessagesEnabled = draftMessagesEnabled, messageSearchSort = messageSearchSort, + 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 b995617771a..42b1fc91d7e 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 @@ -35,6 +35,7 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.InFilterObject import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.OrFilterObject @@ -532,6 +533,128 @@ internal class ChannelListViewModelTest { assertEquals(messageSearchSort, sortCaptor.firstValue) } + @Test + fun `Given predefined filter When initializing Should issue predefined-shaped query`() = runTest { + val chatClient: ChatClient = mock() + Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) + .givenChannelMutes() + .givenPredefined( + name = "vip_filter", + filterValues = mapOf("foo" to "bar"), + sortValues = mapOf("baz" to 1), + ) + .get(this) + + val captor = argumentCaptor() + verify(chatClient).queryChannels(captor.capture()) + val request = captor.firstValue + assertEquals("vip_filter", request.predefinedFilter) + assertEquals(mapOf("foo" to "bar"), request.filterValues) + assertEquals(mapOf("baz" to 1), request.sortValues) + assertEquals(Filters.neutral(), request.filter) + } + + @Test + fun `Given predefined filter When typing channel search Should flip to search-only Standard request`() = runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + .get(this) + + viewModel.setSearchQuery(SearchQuery.Channels("Search query")) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(2)).queryChannels(captor.capture()) + val searchRequest = captor.secondValue + assertNull(searchRequest.predefinedFilter) + assertNull(searchRequest.filterValues) + assertNull(searchRequest.sortValues) + val andFilterObject = searchRequest.filter as AndFilterObject + val autocompleteByName = andFilterObject.filterObjects.first() as AutocompleteFilterObject + val membersIn = andFilterObject.filterObjects.last() as InFilterObject + assertEquals("name", autocompleteByName.fieldName) + assertEquals("Search query", autocompleteByName.value) + assertEquals("members", membersIn.fieldName) + assertTrue("Jc" in membersIn.values) + } + + @Test + fun `Given predefined filter and active search When clearing the search Should revert to predefined request`() = + runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + .get(this) + + viewModel.setSearchQuery(SearchQuery.Channels("Search query")) + advanceUntilIdle() + viewModel.setSearchQuery(SearchQuery.Empty) + advanceUntilIdle() + + val captor = argumentCaptor() + verify(chatClient, times(3)).queryChannels(captor.capture()) + val revertedRequest = captor.thirdValue + assertEquals("vip_filter", revertedRequest.predefinedFilter) + } + + @Test + fun `Given predefined filter When calling setFilters Should not re-issue the query`() = runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + .get(this) + + viewModel.setFilters(Filters.eq("type", "messaging")) + advanceUntilIdle() + + verify(chatClient, times(1)).queryChannels(any()) + } + + @Test + fun `Given predefined filter When calling setQuerySort Should not re-issue the query`() = runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + .get(this) + + viewModel.setQuerySort(QuerySortByField.descByName("created_at")) + advanceUntilIdle() + + verify(chatClient, times(1)).queryChannels(any()) + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), @@ -543,6 +666,9 @@ internal class ChannelListViewModelTest { private val globalState: GlobalState = mock() private val repositoryFacade: RepositoryFacade = mock() private var messageSearchSort: QuerySorter? = null + private var predefinedFilterName: String? = null + private var predefinedFilterValues: Map? = null + private var predefinedSortValues: Map? = null init { val statePlugin: StatePlugin = mock() @@ -597,6 +723,16 @@ internal class ChannelListViewModelTest { messageSearchSort = sort } + fun givenPredefined( + name: String, + filterValues: Map? = null, + sortValues: Map? = null, + ) = apply { + predefinedFilterName = name + predefinedFilterValues = filterValues + predefinedSortValues = sortValues + } + fun givenSearchMessagesResult(result: SearchMessagesResult) = apply { whenever( chatClient.searchMessages(any(), any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()), @@ -623,19 +759,33 @@ internal class ChannelListViewModelTest { whenever(it.endOfChannels) doReturn MutableStateFlow(endOfChannels) whenever(it.nextPageRequest) doReturn MutableStateFlow(nextPageRequest) } - whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState + whenever(stateRegistry.queryChannels(any())) doReturn queryChannelsState } fun get(testScope: TestScope): ChannelListViewModel { - val channelListViewModel = ChannelListViewModel( - chatClient = chatClient, - initialSort = initialSort, - initialFilters = initialFilters, - draftMessagesEnabled = false, - chatEventHandlerFactory = ChatEventHandlerFactory(clientState), - messageSearchSort = messageSearchSort, - globalState = MutableStateFlow(globalState), - ) + val name = predefinedFilterName + val channelListViewModel = if (name != null) { + ChannelListViewModel( + chatClient = chatClient, + predefinedFilterName = name, + filterValues = predefinedFilterValues, + sortValues = predefinedSortValues, + draftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } else { + ChannelListViewModel( + chatClient = chatClient, + initialSort = initialSort, + initialFilters = initialFilters, + draftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } testScope.advanceUntilIdle() return channelListViewModel } diff --git a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt index eaf3f7959f9..8bdb5cff574 100644 --- a/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt +++ b/stream-chat-android-ui-components-sample/src/main/kotlin/io/getstream/chat/ui/sample/feature/channel/list/ChannelListFragment.kt @@ -29,7 +29,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.navigation.findNavController import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.models.Filters import io.getstream.chat.android.ui.common.utils.Utils import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModelFactory @@ -47,6 +46,23 @@ import io.getstream.chat.ui.sample.feature.home.HomeFragmentDirections class ChannelListFragment : Fragment() { + /** + * The provided predefined filter has the following specs: + * + * **Filter:** + * ``` + * Filters.and( + * Filters.eq("type", "messaging"), + * Filters.`in`("members", listOf(currentUserId)), + * Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), + * ) + * ``` + * + * **Sort:** + * ``` + * QuerySortByField.descByName("last_updated") + * ``` + */ private val viewModel: ChannelListViewModel by viewModels { val user = App.instance.userRepository.getUser() val userId = if (user == SampleUser.None) { @@ -54,12 +70,11 @@ class ChannelListFragment : Fragment() { } else { user.id } - ChannelListViewModelFactory( - filter = Filters.and( - Filters.eq("type", "messaging"), - Filters.`in`("members", listOf(userId)), - Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), + predefinedFilterName = "android_sample_filter", + filterValues = mapOf( + "channel_type" to "messaging", + "user_id" to userId, ), chatEventHandlerFactory = CustomChatEventHandlerFactory(), ) diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index d50fd9d9964..4bc78f54b08 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -4259,6 +4259,8 @@ public final class io/getstream/chat/android/ui/viewmodel/channels/ChannelListVi public static final field DEFAULT_SORT Lio/getstream/chat/android/models/querysort/QuerySorter; public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Lio/getstream/chat/android/client/ChatClient;Lkotlinx/coroutines/flow/Flow;)V public synthetic fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Lio/getstream/chat/android/client/ChatClient;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Lio/getstream/chat/android/client/ChatClient;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;Lio/getstream/chat/android/client/ChatClient;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun deleteChannel (Lio/getstream/chat/android/models/Channel;)V public final fun getDraftMessages ()Landroidx/lifecycle/LiveData; public final fun getErrorEvents ()Landroidx/lifecycle/LiveData; @@ -4351,6 +4353,15 @@ public final class io/getstream/chat/android/ui/viewmodel/channels/ChannelListVi public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;Z)V public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;)V public synthetic fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Ljava/util/Map;)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;I)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Z)V + public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; public fun create (Ljava/lang/Class;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; public fun create (Lkotlin/reflect/KClass;Landroidx/lifecycle/viewmodel/CreationExtras;)Landroidx/lifecycle/ViewModel; @@ -4361,11 +4372,14 @@ public final class io/getstream/chat/android/ui/viewmodel/channels/ChannelListVi public final fun build ()Landroidx/lifecycle/ViewModelProvider$Factory; public final fun chatEventHandlerFactory (Lio/getstream/chat/android/client/api/event/ChatEventHandlerFactory;)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; public final fun filter (Lio/getstream/chat/android/models/FilterObject;)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; + public final fun filterValues (Ljava/util/Map;)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; public final fun isDraftMessagesEnabled (Z)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; public final fun limit (I)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; public final fun memberLimit (Ljava/lang/Integer;)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; public final fun messageLimit (Ljava/lang/Integer;)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; + public final fun predefinedFilter (Ljava/lang/String;)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; public final fun sort (Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; + public final fun sortValues (Ljava/util/Map;)Lio/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory$Builder; } public final class io/getstream/chat/android/ui/viewmodel/mentions/MentionListViewModel : androidx/lifecycle/ViewModel { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt index f911d12c566..1ba22ccf7e6 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModel.kt @@ -71,8 +71,6 @@ import kotlinx.coroutines.launch * Responsible for keeping the channels list up to date. * Can be bound to the view using [ChannelListViewModel.bindView] function. * - * @param filter Filter for querying channels, should never be empty. - * @param sort Defines the ordering of the channels. * @param limit The maximum number of channels to fetch. * @param messageLimit The number of messages to fetch for each channel. * When `null`, the server-side default is used. @@ -83,19 +81,84 @@ import kotlinx.coroutines.launch * @param chatClient Entry point for all low-level operations. * @param globalState A flow emitting the current [GlobalState]. */ +@Suppress("LongParameterList") @OptIn(ExperimentalCoroutinesApi::class) -public class ChannelListViewModel( - private val filter: FilterObject? = null, - private val sort: QuerySorter = DEFAULT_SORT, - private val limit: Int = DEFAULT_CHANNEL_LIMIT, - private val messageLimit: Int? = null, - private val memberLimit: Int? = null, +public class ChannelListViewModel internal constructor( + private val mode: QueryMode, + private val limit: Int, + private val messageLimit: Int?, + private val memberLimit: Int?, private val isDraftMessagesEnabled: Boolean, - private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(), - private val chatClient: ChatClient = ChatClient.instance(), - private val globalState: Flow = chatClient.globalStateFlow, + private val chatEventHandlerFactory: ChatEventHandlerFactory, + private val chatClient: ChatClient, + private val globalState: Flow, ) : ViewModel() { + /** + * Creates a view model that queries channels by an explicit filter and sort. + * + * @param filter Filter for querying channels. When `null`, a default filter scoped to messaging + * channels the current user is a member of is used. Can be changed at runtime via [setFilters]. + * @param sort Defines the ordering of the channels. + */ + public constructor( + filter: FilterObject? = null, + sort: QuerySorter = DEFAULT_SORT, + limit: Int = DEFAULT_CHANNEL_LIMIT, + messageLimit: Int? = null, + memberLimit: Int? = null, + isDraftMessagesEnabled: Boolean, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(), + chatClient: ChatClient = ChatClient.instance(), + globalState: Flow = chatClient.globalStateFlow, + ) : this( + mode = QueryMode.Standard(initialFilter = filter, initialSort = sort), + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + isDraftMessagesEnabled = isDraftMessagesEnabled, + chatEventHandlerFactory = chatEventHandlerFactory, + chatClient = chatClient, + globalState = globalState, + ) + + /** + * Creates a view model that queries channels using a predefined filter resolved by the server. + * + * The filter and sort are identified by [predefinedFilterName] and resolved server-side; + * [filterValues] and [sortValues] interpolate into the predefined template. [setFilters] does not + * affect a view model created this way. + * + * @param predefinedFilterName The name of the predefined filter registered on the backend. + * @param filterValues Optional values interpolated into the predefined filter template. + * @param sortValues Optional values interpolated into the predefined sort template. + */ + public constructor( + predefinedFilterName: String, + filterValues: Map? = null, + sortValues: Map? = null, + limit: Int = DEFAULT_CHANNEL_LIMIT, + messageLimit: Int? = null, + memberLimit: Int? = null, + isDraftMessagesEnabled: Boolean, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(), + chatClient: ChatClient = ChatClient.instance(), + globalState: Flow = chatClient.globalStateFlow, + ) : this( + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = filterValues, + sortValues = sortValues, + ), + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + isDraftMessagesEnabled = isDraftMessagesEnabled, + chatEventHandlerFactory = chatEventHandlerFactory, + chatClient = chatClient, + globalState = globalState, + ) + private var queryJob: Job? = null /** @@ -164,9 +227,15 @@ public class ChannelListViewModel( private val logger: TaggedLogger by taggedLogger("Chat:ChannelList-VM") /** - * Filters the requested channels. + * Filters the requested channels. Only meaningful in [QueryMode.Standard]; remains `null` in + * [QueryMode.Predefined] (the server owns the filter). */ - private val filterLiveData: MutableLiveData = MutableLiveData(filter) + private val filterLiveData: MutableLiveData = MutableLiveData( + when (mode) { + is QueryMode.Standard -> mode.initialFilter + is QueryMode.Predefined -> null + }, + ) /** * Represents the current state of the channels query. @@ -174,18 +243,21 @@ public class ChannelListViewModel( private var queryChannelsState: StateFlow = MutableStateFlow(null) init { - if (filter == null) { - viewModelScope.launch { - val filter = buildDefaultFilter().first() - - this@ChannelListViewModel.filterLiveData.value = filter - } - } - - stateMerger.addSource(filterLiveData) { filter -> - if (filter != null) { - initData(filter) + when (mode) { + is QueryMode.Standard -> { + if (mode.initialFilter == null) { + viewModelScope.launch { + val resolvedFilter = buildDefaultFilter().first() + this@ChannelListViewModel.filterLiveData.value = resolvedFilter + } + } + stateMerger.addSource(filterLiveData) { filter -> + if (filter != null) { + initData() + } + } } + is QueryMode.Predefined -> initData() } } @@ -199,24 +271,20 @@ public class ChannelListViewModel( /** * Initializes the data necessary for the screen. */ - private fun initData(filterObject: FilterObject) { + private fun initData() { stateMerger.value = INITIAL_STATE - init(filterObject) + init() } /** * Initializes this ViewModel with OfflinePlugin implementation. It makes the initial query to request channels * and starts to observe state changes. */ - private fun init(filterObject: FilterObject) { - val queryChannelsRequest = - QueryChannelsRequest( - filter = filterObject, - querySort = sort, - limit = limit, - messageLimit = messageLimit, - memberLimit = memberLimit, - ) + private fun init() { + val queryChannelsRequest = buildQueryChannelsRequest() ?: run { + logger.v { "[init] rejected (filter not yet initialized)" } + return + } queryChannelsState = chatClient.queryChannelsAsState(queryChannelsRequest, chatEventHandlerFactory, viewModelScope) @@ -350,34 +418,66 @@ public class ChannelListViewModel( * Called when scrolling to the end of the list. */ private fun requestMoreChannels() { - filterLiveData.value?.let { - val queryChannelsState = queryChannelsState.value ?: return - - queryChannelsState.nextPageRequest.value?.let { - viewModelScope.launch { - chatClient.queryChannels(it).enqueue( - onError = { streamError -> - logger.e { - "Could not load more channels. Error: ${streamError.message}. " + - "Cause: ${streamError.extractCause()}" - } - }, - ) - } - } + if (mode is QueryMode.Standard && filterLiveData.value == null) { + return + } + val queryChannelsState = queryChannelsState.value ?: return + val nextPageRequest = queryChannelsState.nextPageRequest.value ?: return + viewModelScope.launch { + chatClient.queryChannels(nextPageRequest).enqueue( + onError = { streamError -> + logger.e { + "Could not load more channels. Error: ${streamError.message}. " + + "Cause: ${streamError.extractCause()}" + } + }, + ) } } /** * Allows us to change the filter based on our requirements. * + * Has no effect on view models constructed for a predefined-filter query — the predefined identity + * is fixed at construction. A warning is logged in that case. + * * @param filterObject The new filter to be applied to the query which lets us fetch different data. */ public fun setFilters(filterObject: FilterObject) { + if (mode is QueryMode.Predefined) { + logger.w { "[setFilters] ignored — view model uses predefined filter '${mode.name}'" } + return + } logger.d { "[setFilters] filterObject: $filterObject" } this.filterLiveData.value = filterObject } + /** + * Builds a [QueryChannelsRequest] for the current [mode]. Returns `null` in Standard mode when the + * filter has not yet been resolved (e.g. before [buildDefaultFilter] completes). + */ + private fun buildQueryChannelsRequest(): QueryChannelsRequest? = when (mode) { + is QueryMode.Standard -> { + val baseFilter = filterLiveData.value ?: return null + QueryChannelsRequest( + filter = baseFilter, + querySort = mode.initialSort, + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + } + is QueryMode.Predefined -> QueryChannelsRequest( + filter = Filters.neutral(), + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + predefinedFilter = mode.name, + filterValues = mode.filterValues, + sortValues = mode.sortValues, + ) + } + /** * Sets the current pagination state. * @@ -469,6 +569,19 @@ public class ChannelListViewModel( public data class DeleteChannelError(override val streamError: Error) : ErrorEvent(streamError) } + internal sealed interface QueryMode { + data class Standard( + val initialFilter: FilterObject?, + val initialSort: QuerySorter, + ) : QueryMode + + data class Predefined( + val name: String, + val filterValues: Map?, + val sortValues: Map?, + ) : QueryMode + } + public companion object { /** diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt index 28cc4360d4c..5783b6c76a1 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/viewmodel/channels/ChannelListViewModelFactory.kt @@ -26,12 +26,11 @@ import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.ui.ChatUI +import io.getstream.chat.android.ui.viewmodel.channels.ChannelListViewModel.QueryMode /** * Creates a channels view model factory. * - * @param filter How to filter the channels. - * @param sort How to sort the channels, defaults to last_updated. * @param limit How many channels to return. * @param memberLimit The number of members per channel. When `null`, the server-side default is used. * @param messageLimit The number of messages to fetch for each channel. When `null`, the server-side default is used. @@ -41,16 +40,73 @@ import io.getstream.chat.android.ui.ChatUI * @see Filters * @see QuerySorter */ -public class ChannelListViewModelFactory @JvmOverloads constructor( - private val filter: FilterObject? = null, - private val sort: QuerySorter = ChannelListViewModel.DEFAULT_SORT, - private val limit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, - private val messageLimit: Int? = null, - private val memberLimit: Int? = null, - private val isDraftMessagesEnabled: Boolean = ChatUI.draftMessagesEnabled, - private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(), +public class ChannelListViewModelFactory +@Suppress("LongParameterList") +private constructor( + private val mode: QueryMode, + private val limit: Int, + private val messageLimit: Int?, + private val memberLimit: Int?, + private val isDraftMessagesEnabled: Boolean, + private val chatEventHandlerFactory: ChatEventHandlerFactory, ) : ViewModelProvider.Factory { + /** + * Builds a factory for a [ChannelListViewModel] that queries channels by an explicit filter and sort. + * + * @param filter How to filter the channels. When `null`, a default filter scoped to messaging + * channels the current user is a member of is used. + * @param sort How to sort the channels, defaults to last_updated. + */ + @JvmOverloads + public constructor( + filter: FilterObject? = null, + sort: QuerySorter = ChannelListViewModel.DEFAULT_SORT, + limit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + messageLimit: Int? = null, + memberLimit: Int? = null, + isDraftMessagesEnabled: Boolean = ChatUI.draftMessagesEnabled, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(), + ) : this( + mode = QueryMode.Standard(initialFilter = filter, initialSort = sort), + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + isDraftMessagesEnabled = isDraftMessagesEnabled, + chatEventHandlerFactory = chatEventHandlerFactory, + ) + + /** + * Builds a factory for a [ChannelListViewModel] that queries channels using a predefined filter + * resolved by the server. + * + * @param predefinedFilterName The name of the predefined filter registered on the backend. + * @param filterValues Optional values interpolated into the predefined filter template. + * @param sortValues Optional values interpolated into the predefined sort template. + */ + @JvmOverloads + public constructor( + predefinedFilterName: String, + filterValues: Map? = null, + sortValues: Map? = null, + limit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + messageLimit: Int? = null, + memberLimit: Int? = null, + isDraftMessagesEnabled: Boolean = ChatUI.draftMessagesEnabled, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(), + ) : this( + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = filterValues, + sortValues = sortValues, + ), + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + isDraftMessagesEnabled = isDraftMessagesEnabled, + chatEventHandlerFactory = chatEventHandlerFactory, + ) + /** * Returns an instance of [ChannelListViewModel]. */ @@ -58,16 +114,14 @@ public class ChannelListViewModelFactory @JvmOverloads constructor( require(modelClass == ChannelListViewModel::class.java) { "ChannelListViewModelFactory can only create instances of ChannelListViewModel" } - @Suppress("UNCHECKED_CAST") return ChannelListViewModel( - filter = filter, - sort = sort, + mode = mode, limit = limit, messageLimit = messageLimit, memberLimit = memberLimit, - chatEventHandlerFactory = chatEventHandlerFactory, isDraftMessagesEnabled = isDraftMessagesEnabled, + chatEventHandlerFactory = chatEventHandlerFactory, chatClient = ChatClient.instance(), globalState = ChatClient.instance().globalStateFlow, ) as T @@ -80,6 +134,9 @@ public class ChannelListViewModelFactory @JvmOverloads constructor( private var filter: FilterObject? = null private var sort: QuerySorter = ChannelListViewModel.DEFAULT_SORT + private var predefinedFilterName: String? = null + private var filterValues: Map? = null + private var sortValues: Map? = null private var limit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT private var messageLimit: Int? = null private var memberLimit: Int? = null @@ -87,7 +144,7 @@ public class ChannelListViewModelFactory @JvmOverloads constructor( private var isDraftMessagesEnabled: Boolean = ChatUI.draftMessagesEnabled /** - * Sets the way to filter the channels. + * Sets the way to filter the channels. Mutually exclusive with [predefinedFilter]. */ public fun filter(filter: FilterObject): Builder = apply { this.filter = filter @@ -100,6 +157,30 @@ public class ChannelListViewModelFactory @JvmOverloads constructor( this.sort = sort } + /** + * Configures the factory to query channels via a predefined filter resolved by the server. + * Mutually exclusive with [filter]. + */ + public fun predefinedFilter(name: String): Builder = apply { + this.predefinedFilterName = name + } + + /** + * Sets the values interpolated into the predefined filter template. Has no effect unless + * [predefinedFilter] was called. + */ + public fun filterValues(values: Map): Builder = apply { + this.filterValues = values + } + + /** + * Sets the values interpolated into the predefined sort template. Has no effect unless + * [predefinedFilter] was called. + */ + public fun sortValues(values: Map): Builder = apply { + this.sortValues = values + } + /** * Sets the number of channels to return. */ @@ -139,17 +220,36 @@ public class ChannelListViewModelFactory @JvmOverloads constructor( /** * Builds [ChannelListViewModelFactory] instance. + * + * @throws IllegalStateException if both [filter] and [predefinedFilter] were set. */ public fun build(): ViewModelProvider.Factory { - return ChannelListViewModelFactory( - filter = filter, - sort = sort, - limit = limit, - messageLimit = messageLimit, - memberLimit = memberLimit, - chatEventHandlerFactory = chatEventHandlerFactory, - isDraftMessagesEnabled = isDraftMessagesEnabled, - ) + val name = predefinedFilterName + return if (name != null) { + check(filter == null) { + "ChannelListViewModelFactory.Builder: filter() and predefinedFilter() are mutually exclusive." + } + ChannelListViewModelFactory( + predefinedFilterName = name, + filterValues = filterValues, + sortValues = sortValues, + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + isDraftMessagesEnabled = isDraftMessagesEnabled, + ) + } else { + ChannelListViewModelFactory( + filter = filter, + sort = sort, + limit = limit, + messageLimit = messageLimit, + memberLimit = memberLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + isDraftMessagesEnabled = isDraftMessagesEnabled, + ) + } } } } diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt index db2dee2e639..f81cdea0c0d 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/viewmodels/channels/ChannelListViewModelTest.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be equal to` import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.any @@ -209,6 +210,49 @@ internal class ChannelListViewModelTest { viewModel.state.removeObserver(mockObserver) } + @Test + fun `Given predefined filter When initializing Should issue predefined-shaped query`() = runTest { + val chatClient: ChatClient = mock() + Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) + .givenChannelMutes() + .givenPredefined( + name = "vip_filter", + filterValues = mapOf("foo" to "bar"), + sortValues = mapOf("baz" to 1), + ) + .get() + + val captor = argumentCaptor() + verify(chatClient).queryChannels(captor.capture()) + val request = captor.firstValue + assertEquals("vip_filter", request.predefinedFilter) + assertEquals(mapOf("foo" to "bar"), request.filterValues) + assertEquals(mapOf("baz" to 1), request.sortValues) + assertEquals(Filters.neutral(), request.filter) + } + + @Test + fun `Given predefined filter When calling setFilters Should not re-issue the query`() = runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsQuery() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + loading = false, + ) + .givenChannelMutes() + .givenPredefined(name = "vip_filter") + .get() + + viewModel.setFilters(Filters.eq("type", "messaging")) + + verify(chatClient, times(1)).queryChannels(any()) + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), @@ -219,6 +263,9 @@ internal class ChannelListViewModelTest { private val stateRegistry: StateRegistry = mock() private val clientState: ClientState = mock() private val globalState: GlobalState = mock() + private var predefinedFilterName: String? = null + private var predefinedFilterValues: Map? = null + private var predefinedSortValues: Map? = null init { whenever(chatClient.channel(any())) doReturn channelClient @@ -253,6 +300,16 @@ internal class ChannelListViewModelTest { whenever(channelClient.delete()) doReturn Channel().asCall() } + fun givenPredefined( + name: String, + filterValues: Map? = null, + sortValues: Map? = null, + ) = apply { + predefinedFilterName = name + predefinedFilterValues = filterValues + predefinedSortValues = sortValues + } + fun givenChannelsState( channelsStateData: ChannelsStateData = ChannelsStateData.Loading, channels: List? = null, @@ -269,20 +326,35 @@ internal class ChannelListViewModelTest { whenever(it.endOfChannels) doReturn MutableStateFlow(endOfChannels) whenever(it.nextPageRequest) doReturn MutableStateFlow(nextPageRequest) } - whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState + whenever(stateRegistry.queryChannels(any())) doReturn queryChannelsState } fun get(): ChannelListViewModel { - return ChannelListViewModel( - chatClient = chatClient, - sort = initialSort, - filter = initialFilters, - isDraftMessagesEnabled = false, - chatEventHandlerFactory = ChatEventHandlerFactory( - clientState = clientState, - ), - globalState = MutableStateFlow(globalState), - ) + val name = predefinedFilterName + return if (name != null) { + ChannelListViewModel( + chatClient = chatClient, + predefinedFilterName = name, + filterValues = predefinedFilterValues, + sortValues = predefinedSortValues, + isDraftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory( + clientState = clientState, + ), + globalState = MutableStateFlow(globalState), + ) + } else { + ChannelListViewModel( + chatClient = chatClient, + sort = initialSort, + filter = initialFilters, + isDraftMessagesEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory( + clientState = clientState, + ), + globalState = MutableStateFlow(globalState), + ) + } } }