From 689df380944a5cbfe1e770439d7a29270ec64c1f Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 7 May 2026 21:03:26 +0200 Subject: [PATCH 1/2] Add support for `PredefinedFilters` for `QueryChannels`. --- .../chat/android/client/test/Mother.kt | 12 +- .../api/stream-chat-android-client.api | 67 ++++- .../chat/android/client/ChatClient.kt | 10 +- .../chat/android/client/api/ChatApi.kt | 3 +- .../client/api/internal/DistinctChatApi.kt | 3 +- .../api/internal/DistinctChatApiEnabler.kt | 3 +- .../client/api/models/PredefinedFilter.kt | 36 +++ .../client/api/models/QueryChannelsRequest.kt | 18 +- .../client/api/models/QueryChannelsResult.kt | 33 +++ .../chat/android/client/api2/MoshiChatApi.kt | 28 +- .../client/api2/mapping/DomainMapping.kt | 16 + .../api2/mapping/FilterDomainMapping.kt | 140 +++++++++ .../model/requests/QueryChannelsRequest.kt | 12 +- .../model/response/QueryChannelsResponse.kt | 8 + .../state/plugin/QueryChannelsIdentifier.kt | 85 ++++++ .../repository/QueryChannelsRepository.kt | 29 +- .../noop/NoOpQueryChannelsRepository.kt | 10 + .../client/plugin/MessageDeliveredPlugin.kt | 10 +- .../plugin/listeners/QueryChannelsListener.kt | 37 +++ .../android/client/query/QueryChannelsSpec.kt | 34 ++- .../client/ChatClientChannelApiTests.kt | 29 +- .../getstream/chat/android/client/Mother.kt | 6 + .../android/client/api2/MoshiChatApiTest.kt | 42 +++ .../client/api2/MoshiChatApiTestArguments.kt | 21 ++ .../client/api2/mapping/DomainMappingTest.kt | 56 ++++ .../api2/mapping/FilterDomainMappingTest.kt | 241 +++++++++++++++ .../api2/mapping/FilterObjectRoundTripTest.kt | 120 ++++++++ .../mapping/QuerySortByFieldRoundTripTest.kt | 67 +++++ .../plugin/QueryChannelsIdentifierTest.kt | 119 ++++++++ .../plugin/MessageDeliveredPluginTest.kt | 8 +- .../feature/channel/list/ChannelsActivity.kt | 35 ++- .../api/stream-chat-android-compose.api | 12 + .../channels/ChannelListViewModel.kt | 276 ++++++++++++++---- .../channels/ChannelViewModelFactory.kt | 94 +++++- .../channels/ChannelListViewModelTest.kt | 3 +- .../internal/NullableMapConverter.kt | 42 +++ .../database/internal/ChatDatabase.kt | 2 +- .../DatabaseQueryChannelsRepository.kt | 46 +-- .../internal/QueryChannelsEntity.kt | 19 ++ .../getstream/chat/android/offline/Mother.kt | 13 +- .../QueryChannelsImplRepositoryTest.kt | 48 ++- .../converter/NullableMapConverterTest.kt | 79 +++++ .../QueryChannelsEntityConverterTest.kt | 187 ++++++++++++ .../internal/QueryChannelsListenerState.kt | 26 +- .../plugin/logic/internal/LogicRegistry.kt | 17 +- .../internal/QueryChannelsDatabaseLogic.kt | 40 +-- .../internal/QueryChannelsLogic.kt | 118 +++++--- .../internal/QueryChannelsStateLogic.kt | 15 +- .../state/plugin/state/StateRegistry.kt | 37 ++- .../state/internal/ChatClientStateCalls.kt | 3 +- .../internal/QueryChannelsMutableState.kt | 86 +++++- .../state/sync/internal/SyncManager.kt | 2 +- .../QueryChannelsListenerStateTest.kt | 158 ++++++++++ .../QueryChannelsDatabaseLogicTest.kt | 49 ++-- .../internal/QueryChannelsLogicTest.kt | 18 +- .../internal/QueryChannelsStateLogicTest.kt | 13 +- .../internal/QueryChannelsMutableStateTest.kt | 138 +++++++++ .../channel/list/ChannelListFragment.kt | 29 +- .../api/stream-chat-android-ui-components.api | 14 + .../channels/ChannelListViewModel.kt | 217 ++++++++++---- .../channels/ChannelListViewModelFactory.kt | 148 ++++++++-- .../channels/ChannelListViewModelTest.kt | 3 +- 62 files changed, 2916 insertions(+), 374 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/PredefinedFilter.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/models/QueryChannelsResult.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMapping.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterDomainMappingTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/FilterObjectRoundTripTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/QuerySortByFieldRoundTripTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifierTest.kt create mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/converter/internal/NullableMapConverter.kt create mode 100644 stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/NullableMapConverterTest.kt create mode 100644 stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntityConverterTest.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index 2194011100d..122e9dc0036 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 @@ -661,7 +661,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 4b8e6d4e130..aa448e75844 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -484,25 +484,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 @@ -2799,6 +2813,34 @@ public abstract interface class io/getstream/chat/android/client/interceptor/mes public abstract fun prepareMessage (Lio/getstream/chat/android/models/Message;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/models/Message; } +public final class io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$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; @@ -3043,7 +3085,10 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract interface class io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository { public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun insertQueryChannels (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun selectBy (Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun selectBy$suspendImpl (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun selectBy$suspendImpl (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/persistance/repository/ReactionRepository { @@ -3341,6 +3386,8 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener public static synthetic fun onQueryChannelsRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryChannelsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryChannelsResultWithPredefinedFilter (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryChannelsResultWithPredefinedFilter$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryMembersListener { @@ -3452,16 +3499,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 f0ae9d84b38..ba9d98e283e 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 @@ -2990,7 +2990,7 @@ internal constructor( @CheckResult @InternalStreamChatApi public fun queryChannelsInternal(request: QueryChannelsRequest): Call> { - return api.queryChannels(request) + return api.queryChannels(request).map { it.channels } } /** @@ -3017,7 +3017,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()) { @@ -3128,7 +3128,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) @@ -3136,10 +3136,12 @@ internal constructor( }.doOnResult(userScope) { result -> plugins.forEach { listener -> logger.v { "[queryChannels] #doOnResult; plugin: ${listener::class.qualifiedName}" } - listener.onQueryChannelsResult(result, request) + listener.onQueryChannelsResultWithPredefinedFilter(result, request) } }.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 7b6225e791f..713b7cbfe5a 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 @@ -286,7 +287,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/api2/MoshiChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt index da2a06b54c7..63c51df5efa 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 @@ -44,6 +46,7 @@ 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.toDomain +import io.getstream.chat.android.client.api2.mapping.toFilterDomain import io.getstream.chat.android.client.api2.model.dto.DownstreamPendingMessageDto import io.getstream.chat.android.client.api2.model.dto.PartialUpdateUserDto import io.getstream.chat.android.client.api2.model.dto.UpstreamPushPreferenceInputDto @@ -1286,12 +1289,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, @@ -1303,7 +1309,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 a5939ca392b..d7c3e937c2c 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 @@ -123,6 +123,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") @@ -894,4 +897,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/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..c65ea72ecaa --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt @@ -0,0 +1,85 @@ +/* + * 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`. + */ +@InternalStreamChatApi +public 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`. + */ +@InternalStreamChatApi +public 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/persistance/repository/QueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt index 54818400314..d4a25df5576 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.persistance.repository +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject @@ -34,12 +35,36 @@ public interface QueryChannelsRepository { public suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) /** - * Selects by a filter and query sort. + * Selects by a filter and query sort. Kept for backwards compatibility with custom + * implementations written before predefined filters existed. * * @param filter [FilterObject] * @param querySort [QuerySorter] */ - public suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? + @Deprecated( + message = "Use selectBy(QueryChannelsIdentifier) instead. " + + "This overload cannot represent server-side predefined-filter queries.", + replaceWith = ReplaceWith( + "selectBy(QueryChannelsIdentifier.Standard(filter, querySort))", + "io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier", + ), + ) + public suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? = null + + /** + * Selects a query spec by its identifier. Default implementation delegates to the legacy + * [selectBy(filter, querySort)] for [QueryChannelsIdentifier.Standard] (so existing custom + * implementations of the legacy overload keep working) and returns `null` for + * [QueryChannelsIdentifier.Predefined] (no offline data — falls back to network). + * + * @param identifier The query spec identifier. + */ + public suspend fun selectBy(identifier: QueryChannelsIdentifier): QueryChannelsSpec? = when (identifier) { + is QueryChannelsIdentifier.Standard -> + @Suppress("DEPRECATION") + selectBy(identifier.filter, identifier.sort) + is QueryChannelsIdentifier.Predefined -> null + } /** * 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..d5e82fc65f5 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,6 +16,7 @@ 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 @@ -27,6 +28,15 @@ import io.getstream.chat.android.models.querysort.QuerySorter */ internal object NoOpQueryChannelsRepository : QueryChannelsRepository { override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) { /* No-Op */ } + + @Deprecated( + message = "Use selectBy(QueryChannelsIdentifier) instead.", + replaceWith = ReplaceWith( + "selectBy(QueryChannelsIdentifier.Standard(filter, querySort))", + "io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier", + ), + ) 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..543dac15d4d 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,12 @@ 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 onQueryChannelsResultWithPredefinedFilter( + 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 52f0c0b3357..01f6c16c81e 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,6 +19,7 @@ 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.models.Channel import io.getstream.result.Result @@ -47,9 +48,45 @@ public interface QueryChannelsListener { /** * Runs this function on the [Result] of this [QueryChannelsRequest]. + * + * Kept for backwards compatibility with listeners written before predefined filters existed — + * for backwards compatibility, this method is still called internally by the new, + * non-deprecated [onQueryChannelsResultWithPredefinedFilter] when that method's default + * implementation runs. */ + @Deprecated( + message = "This method will be removed in the future. " + + "Use QueryChannelsListener#onQueryChannelsResultWithPredefinedFilter(result, request) instead. " + + "For backwards compatibility, this method is still called internally by the new, " + + "non-deprecated method when its default implementation runs.", + replaceWith = ReplaceWith( + "onQueryChannelsResultWithPredefinedFilter(result.map { QueryChannelsResult(it, null) }, request)", + "io.getstream.chat.android.client.api.models.QueryChannelsResult", + ), + ) public suspend fun onQueryChannelsResult( result: Result>, request: QueryChannelsRequest, ) { /* No-Op */ } + + /** + * Runs this function on the [Result] of this [QueryChannelsRequest], exposing the optional + * server-resolved [QueryChannelsResult.predefinedFilter] alongside the channel list. + * + * The default implementation forwards to the deprecated [onQueryChannelsResult] overload, so + * listeners written before predefined-filter support keep receiving callbacks. Override this + * method (instead of the deprecated overload) when you want to inspect the resolved + * predefined filter. + * + * The Kotlin name differs from the deprecated overload because both signatures erase to the + * same JVM signature; this also keeps the deprecated overload's JVM symbol intact for binary + * compatibility with already-compiled customer overrides. + */ + public suspend fun onQueryChannelsResultWithPredefinedFilter( + result: Result, + request: QueryChannelsRequest, + ) { + @Suppress("DEPRECATION") + onQueryChannelsResult(result.map { it.channels }, request) + } } 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 33873d8f293..5d1620e53fc 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() @@ -1239,16 +1240,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).onQueryChannelsResultWithPredefinedFilter(expectedQueryChannelsResult, request) } @Test @@ -1259,16 +1262,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).onQueryChannelsResultWithPredefinedFilter(expectedQueryChannelsResult, request) } @Test @@ -1633,7 +1638,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 6c60ac340ae..f914a8eb5dd 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 @@ -589,6 +589,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, @@ -597,6 +600,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 c228cf4520a..21777dda9a4 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 @@ -1892,6 +1892,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 28bc8b72ab8..d4b5996abf5 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 @@ -438,6 +438,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 c6b5b84507a..9ffe0f3f475 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 @@ -60,6 +60,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.dto.DownstreamThreadParticipantDto import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.models.Answer @@ -67,6 +68,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 @Suppress("LargeClass") internal class DomainMappingTest { @@ -978,4 +986,52 @@ internal class DomainMappingTest { ) } } + + /** + * [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"), + ), + ) + } } 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/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/plugin/MessageDeliveredPluginTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt index 661d5e832cc..b1ffe734896 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,10 @@ internal class MessageDeliveredPluginTest { val fixture = Fixture() val sut = fixture.get() - sut.onQueryChannelsResult(result = Result.Success(channels), request = mock()) + sut.onQueryChannelsResultWithPredefinedFilter( + result = Result.Success(QueryChannelsResult(channels, null)), + request = mock(), + ) fixture.verifyMarkChannelsAsDeliveredCalled(channels = channels) } @@ -48,7 +52,7 @@ internal class MessageDeliveredPluginTest { val fixture = Fixture() val sut = fixture.get() - sut.onQueryChannelsResult(result = Result.Failure(mock()), request = mock()) + sut.onQueryChannelsResultWithPredefinedFilter(result = Result.Failure(mock()), request = mock()) fixture.verifyMarkChannelsAsDeliveredCalled(never()) } 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 cb861a8f810..31404599b2e 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 @@ -60,7 +60,6 @@ import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.compose.sample.ChatApp 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 @@ -94,11 +93,9 @@ import io.getstream.chat.android.compose.viewmodel.mentions.MentionListViewModel import io.getstream.chat.android.compose.viewmodel.threads.ThreadListViewModel 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.User -import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.state.extensions.globalStateFlow import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -110,18 +107,34 @@ 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("pinned_at") + * .descByName("last_updated") + * ``` + */ private val channelsViewModelFactory by lazy { val chatClient = ChatClient.instance() val currentUserId = chatClient.getCurrentUser()?.id ?: "" ChannelViewModelFactory( chatClient = chatClient, - querySort = QuerySortByField - .descByName("pinned_at") // pinned channels first - .desc("last_updated"), // then by 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_v6", + filterValues = mapOf( + "channel_type" to "messaging", + "user_id" to currentUserId, ), chatEventHandlerFactory = CustomChatEventHandlerFactory(), isDraftMessageEnabled = true, @@ -238,7 +251,7 @@ class ChannelsActivity : ComponentActivity() { viewModelFactory = channelsViewModelFactory, title = stringResource(id = R.string.app_name), isShowingHeader = true, - searchMode = SearchMode.Messages, + searchMode = SearchMode.Channels, onChannelClick = ::openMessages, onSearchMessageItemClick = ::openMessages, onBackPressed = ::finish, diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 7739de4f09b..6b465f97d20 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4896,6 +4896,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public static final field $stable I public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun deleteConversation (Lio/getstream/chat/android/models/Channel;)V public final fun dismissChannelAction ()V @@ -4927,8 +4929,18 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelL public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory : androidx/lifecycle/ViewModelProvider$Factory { public static final field $stable I public fun ()V + public fun (Lio/getstream/chat/android/client/ChatClient;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;I)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;Z)V public fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/querysort/QuerySorter;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index 71e60fcd1bb..2a8c1715a0b 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 isDraftMessageEnabled: Boolean = false, - 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 isDraftMessageEnabled: 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, + isDraftMessageEnabled: Boolean = false, + 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, + isDraftMessageEnabled = isDraftMessageEnabled, + 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, + isDraftMessageEnabled: Boolean = false, + 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, + isDraftMessageEnabled = isDraftMessageEnabled, + 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 @@ -243,7 +325,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() @@ -261,27 +343,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 { @@ -404,15 +485,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, @@ -469,6 +547,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 [optimizedChannelSearchFilter] (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. * @@ -498,6 +615,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), @@ -505,6 +627,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. */ @@ -545,13 +673,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) } @@ -559,8 +693,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) } @@ -585,11 +726,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)" } @@ -603,10 +739,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 @@ -768,7 +913,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. @@ -776,6 +921,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/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index fbd2c9afd3c..d0222d282fc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.viewmodel.channels import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel.QueryMode import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Message @@ -26,14 +27,13 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.globalStateFlow /** * Builds the factory that contains all the dependencies required for the Channels Screen. * It currently provides the [ChannelListViewModel] using those dependencies. * * @param chatClient The client used to fetch data. - * @param querySort The sorting order for channels. - * @param filters The base filters used to filter out channels. * @param channelLimit How many channels we fetch per page. * @param memberLimit How many members are fetched for each channel item when loading channels. * When `null`, the server-side default is used. @@ -42,18 +42,81 @@ import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandl * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. * @param messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. */ -public class ChannelViewModelFactory( - private val chatClient: ChatClient = ChatClient.instance(), - private val querySort: QuerySorter = QuerySortByField.descByName("last_updated"), - private val filters: FilterObject? = null, - private val channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, - private val memberLimit: Int? = null, - private val messageLimit: Int? = null, - private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), - private val isDraftMessageEnabled: Boolean = false, - private val messageSearchSort: QuerySorter? = null, +@Suppress("LongParameterList") +public class ChannelViewModelFactory internal constructor( + private val chatClient: ChatClient, + private val mode: QueryMode, + private val channelLimit: Int, + private val memberLimit: Int?, + private val messageLimit: Int?, + private val chatEventHandlerFactory: ChatEventHandlerFactory, + private val isDraftMessageEnabled: Boolean, + private val messageSearchSort: QuerySorter?, ) : ViewModelProvider.Factory { + /** + * 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), + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilter = filters, initialSort = querySort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + ) + + /** + * 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), + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Predefined( + name = predefinedFilterName, + filterValues = filterValues, + sortValues = sortValues, + ), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + ) + /** * Create a new instance of [ChannelListViewModel] class. */ @@ -64,14 +127,15 @@ public class ChannelViewModelFactory( @Suppress("UNCHECKED_CAST") return ChannelListViewModel( chatClient = chatClient, - initialSort = querySort, - initialFilters = filters, + mode = mode, channelLimit = channelLimit, - messageLimit = messageLimit, memberLimit = memberLimit, + messageLimit = messageLimit, chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = ChannelListViewModel.SEARCH_DEBOUNCE_MS, isDraftMessageEnabled = isDraftMessageEnabled, messageSearchSort = messageSearchSort, + 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 f9c54285f30..1998181f89e 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 @@ -19,6 +19,7 @@ package io.getstream.chat.android.compose.viewmodel.channels import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.channel.ChannelClient +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.compose.state.channels.list.ItemState @@ -616,7 +617,7 @@ 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 { diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/converter/internal/NullableMapConverter.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/converter/internal/NullableMapConverter.kt new file mode 100644 index 00000000000..a11dceebdf9 --- /dev/null +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/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.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-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt index e66c9b566a0..1d89b7eca61 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt @@ -88,7 +88,7 @@ import io.getstream.chat.android.offline.repository.domain.user.internal.UserEnt ThreadOrderEntity::class, DraftMessageEntity::class, ], - version = 98, + version = 99, exportSchema = false, ) @TypeConverters( diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt index 728bc67ed12..59cb03f3034 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt @@ -16,11 +16,10 @@ package io.getstream.chat.android.offline.repository.domain.queryChannels.internal +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository import io.getstream.chat.android.client.query.QueryChannelsSpec -import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.FilterObject -import io.getstream.chat.android.models.querysort.QuerySorter /** * Repository for queries of channels. This implementation uses the database. @@ -38,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-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt index 30b03b70c76..785c0bd04b1 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt @@ -18,17 +18,36 @@ package io.getstream.chat.android.offline.repository.domain.queryChannels.intern import androidx.room.Entity import androidx.room.PrimaryKey +import androidx.room.TypeConverters import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.querysort.QuerySorter +import io.getstream.chat.android.offline.repository.database.converter.internal.NullableMapConverter +/** + * 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-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt index cf73986e7c7..ff6e7b61a74 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt @@ -250,7 +250,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-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt index 0c14c7d3c19..3084a9e9ee3 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.offline.repository +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 @@ -32,12 +33,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 @@ -72,6 +77,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"), @@ -79,7 +88,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() @@ -92,8 +101,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-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/NullableMapConverterTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/NullableMapConverterTest.kt new file mode 100644 index 00000000000..5ce23bcd02b --- /dev/null +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/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.offline.repository.database.converter + +import io.getstream.chat.android.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-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntityConverterTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntityConverterTest.kt new file mode 100644 index 00000000000..8e9103daecb --- /dev/null +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/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.offline.repository.domain.queryChannels.internal + +import androidx.test.ext.junit.runners.AndroidJUnit4 +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 io.getstream.chat.android.offline.createRoomDB +import io.getstream.chat.android.offline.randomQueryChannelsEntity +import io.getstream.chat.android.offline.repository.database.internal.ChatDatabase +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-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt index 4ee12d6c2bc..d07509ee6e5 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt @@ -17,9 +17,10 @@ package io.getstream.chat.android.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.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.chat.android.state.model.querychannels.pagination.internal.QueryChannelsPaginationRequest import io.getstream.chat.android.state.model.querychannels.pagination.internal.toAnyChannelPaginationRequest import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry @@ -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,29 @@ 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 onQueryChannelsResultWithPredefinedFilter( + 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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt index b346340cb8d..a74901a8dbf 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt @@ -21,9 +21,10 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelStateLogicProvider import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState -import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread @@ -66,17 +67,18 @@ internal class LogicRegistry internal constructor( private val now: () -> Long, ) : ChannelStateLogicProvider { - private val queryChannels: ConcurrentHashMap>, QueryChannelsLogic> = + private val queryChannels: ConcurrentHashMap = ConcurrentHashMap() private val channels: ConcurrentHashMap, ChannelLogic> = ConcurrentHashMap() private val queryThreads: ConcurrentHashMap?>, QueryThreadsLogic> = ConcurrentHashMap() private val threads: ConcurrentHashMap = ConcurrentHashMap() - internal fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsLogic { - return queryChannels.getOrPut(filter to sort) { + /** 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, @@ -90,8 +92,7 @@ internal class LogicRegistry internal constructor( ) QueryChannelsLogic( - filter, - sort, + identifier, client, queryChannelsStateLogic, queryChannelsDatabaseLogic, @@ -101,7 +102,7 @@ internal class LogicRegistry internal constructor( /** Returns [QueryChannelsLogic] accordingly to [QueryChannelsRequest]. */ internal fun queryChannels(queryChannelsRequest: QueryChannelsRequest): QueryChannelsLogic = - queryChannels(queryChannelsRequest.filter, queryChannelsRequest.querySort) + queryChannels(queryChannelsRequest.identifier) /** Returns [ChannelLogic] by channelType and channelId combination. */ fun channel(channelType: String, channelId: String): ChannelLogic { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt index d35b7e083d0..0005b79e098 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.extensions.internal.applyPagination +import io.getstream.chat.android.client.internal.state.plugin.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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 2b234a19410..9108175eabf 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -20,8 +20,8 @@ import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.CidEvent +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest -import io.getstream.chat.android.client.query.request.ChannelFilterRequest.filterWithOffset import io.getstream.chat.android.models.Channel 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,14 +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) } } @@ -80,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) } /** @@ -149,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) @@ -219,7 +226,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) @@ -236,16 +243,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, @@ -256,7 +262,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 @@ -267,6 +273,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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index f1d7b9a1812..3cf2e5fa12b 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -24,7 +24,9 @@ import io.getstream.chat.android.client.extensions.internal.toCid import io.getstream.chat.android.client.extensions.internal.users 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.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.state.plugin.state.StateRegistry @@ -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-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt index 5f55d9e7797..f093c6328b1 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt @@ -20,11 +20,15 @@ import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.events.ChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Location 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.chat.android.state.event.handler.internal.batch.BatchEvent import io.getstream.chat.android.state.plugin.config.MessageLimitConfig @@ -65,7 +69,7 @@ public class StateRegistry( private val logger by taggedLogger("Chat:StateRegistry") - private val queryChannels: ConcurrentHashMap>, QueryChannelsMutableState> = + private val queryChannels: ConcurrentHashMap = ConcurrentHashMap() private val channels: ConcurrentHashMap, ChannelMutableState> = ConcurrentHashMap() private val queryThreads: ConcurrentHashMap>, QueryThreadsMutableState> = @@ -80,9 +84,34 @@ public class StateRegistry( * * @return [QueryChannelsState] object. */ - public fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsState { - return queryChannels.getOrPut(filter to sort) { - QueryChannelsMutableState(filter, sort, scope, latestUsers, activeLiveLocations) + public fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsState = + queryChannels(QueryChannelsIdentifier.Standard(filter, sort)) + + /** + * Returns [QueryChannelsState] associated with the given [identifier]. 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-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt index df8ec2b73b3..fc4b986f680 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.extensions.state @@ -66,7 +67,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-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt index 64a33bc7a13..e4ade7e3bbe 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt @@ -20,6 +20,7 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.extensions.internal.updateLiveLocations import io.getstream.chat.android.client.extensions.internal.updateUsers +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject @@ -37,12 +38,25 @@ 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>, @@ -50,14 +64,38 @@ internal class QueryChannelsMutableState( private val logger by taggedLogger("Chat: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. @@ -78,12 +116,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) @@ -175,10 +212,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-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index 68f2cb6f81c..db4c5571887 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -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-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt new file mode 100644 index 00000000000..dfc56731f07 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerStateTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.plugin.listener.internal + +import io.getstream.chat.android.client.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.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.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic +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 `onQueryChannelsResultWithPredefinedFilter 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.onQueryChannelsResultWithPredefinedFilter(result, request) + + // Then + verify(queryChannelsLogic).applyResolvedSpec(eq(resolvedFilter), eq(resolvedSort)) + } + + @Test + fun `onQueryChannelsResultWithPredefinedFilter 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.onQueryChannelsResultWithPredefinedFilter(result, request) + + // Then – falls back to QuerySortByField default + verify(queryChannelsLogic).applyResolvedSpec(eq(resolvedFilter), any()) + } + + @Test + fun `onQueryChannelsResultWithPredefinedFilter 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.onQueryChannelsResultWithPredefinedFilter(result, request) + + // Then + verify(queryChannelsLogic, never()).applyResolvedSpec(any(), any()) + } + + @Test + fun `onQueryChannelsResultWithPredefinedFilter 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.onQueryChannelsResultWithPredefinedFilter(result, request) + + // Then + verify(queryChannelsLogic, never()).applyResolvedSpec(any(), any()) + } + + @Test + fun `onQueryChannelsResultWithPredefinedFilter 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.onQueryChannelsResultWithPredefinedFilter(result, request) + + // Then + verify(queryChannelsLogic).onQueryChannelsResult(any(), eq(request)) + assertTrue(queryingChannelsFree.value) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt index 1305bd698d9..362f7b230dc 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogicTest.kt @@ -16,11 +16,11 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.persistance.repository.ChannelConfigRepository import io.getstream.chat.android.client.persistance.repository.ChannelRepository import io.getstream.chat.android.client.persistance.repository.QueryChannelsRepository 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-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 089523a6e7a..c08a0a168fa 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.client.test.randomNewMessageEvent @@ -51,6 +52,7 @@ internal class QueryChannelsLogicTest { private lateinit var filter: FilterObject private lateinit var sort: QuerySortByField + private lateinit var identifier: QueryChannelsIdentifier 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, @@ -148,7 +150,7 @@ internal class QueryChannelsLogicTest { // Then verify(queryChannelsDatabaseLogic).fetchChannelsFromCache( eq(pagination), - eq(queryChannelsSpec), + eq(identifier), ) } @@ -181,8 +183,9 @@ 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) @@ -200,9 +203,9 @@ internal class QueryChannelsLogicTest { 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 +223,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 diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt index 4845c0ec1d7..f780a768095 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt @@ -30,7 +30,6 @@ import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.chat.android.test.TestCoroutineRule import kotlinx.coroutines.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-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt new file mode 100644 index 00000000000..c70e1567cd0 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/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.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-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..86fa7cf3f9f 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,25 @@ 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("pinned_at") + * .descByName("last_updated") + * ``` * ``` + */ private val viewModel: ChannelListViewModel by viewModels { val user = App.instance.userRepository.getUser() val userId = if (user == SampleUser.None) { @@ -54,12 +72,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_v6", + 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 ca0708e9f2c..1ecb1a76c82 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 @@ -4372,6 +4372,8 @@ public final class io/getstream/chat/android/ui/viewmodel/channels/ChannelListVi 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/state/event/handler/chat/factory/ChatEventHandlerFactory;Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/state/plugin/state/global/GlobalState;ILkotlin/jvm/internal/DefaultConstructorMarker;)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/state/event/handler/chat/factory/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/state/event/handler/chat/factory/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/state/event/handler/chat/factory/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/state/event/handler/chat/factory/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; @@ -4476,6 +4478,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/state/event/handler/chat/factory/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/state/event/handler/chat/factory/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/state/event/handler/chat/factory/ChatEventHandlerFactory;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;ZLio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } @@ -4484,11 +4495,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/state/event/handler/chat/factory/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 e8366173063..7df4fd50a10 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 @@ -73,8 +73,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. @@ -85,34 +83,89 @@ 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, + ) + /** * ViewModel class for [ChannelListView]. * 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. - * @param memberLimit The number of members to fetch per channel. - * When `null`, the server-side default is used. - * @param isDraftMessagesEnabled Enables or disables draft messages. - * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] that will be used to create [ChatEventHandler]. - * @param chatClient Entry point for all low-level operations. * @param globalState The current [GlobalState]. */ @Deprecated("Use the constructor which accepts a Flow for the globalState instead.") @@ -206,9 +259,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. @@ -216,18 +275,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() } } @@ -241,24 +303,46 @@ 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. + * 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 init(filterObject: FilterObject) { - val queryChannelsRequest = + private fun buildQueryChannelsRequest(): QueryChannelsRequest? = when (mode) { + is QueryMode.Standard -> { + val baseFilter = filterLiveData.value ?: return null QueryChannelsRequest( - filter = filterObject, - querySort = sort, + 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, + ) + } + + /** + * Initializes this ViewModel with OfflinePlugin implementation. It makes the initial query to request channels + * and starts to observe state changes. + */ + private fun init() { + val queryChannelsRequest = buildQueryChannelsRequest() ?: run { + logger.v { "[init] rejected (filter not yet initialized)" } + return + } queryChannelsState = chatClient.queryChannelsAsState(queryChannelsRequest, chatEventHandlerFactory, viewModelScope) @@ -412,30 +496,36 @@ 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 } @@ -538,6 +628,19 @@ public class ChannelListViewModel( public data class HideChannelError(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 1601f90aa0f..887d54b8f5c 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.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.extensions.globalStateFlow 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 bbdfe431876..bb35979f4b3 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 @@ -20,6 +20,7 @@ import androidx.lifecycle.Observer import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.channel.ChannelClient +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelMute @@ -297,7 +298,7 @@ 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 { From daccbddc7cdcee5a51caa464d8cfbe7877b05c56 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 8 May 2026 09:15:55 +0200 Subject: [PATCH 2/2] Revert default search mode. --- .../compose/sample/feature/channel/list/ChannelsActivity.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 31404599b2e..c49c070e874 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 @@ -251,7 +251,7 @@ class ChannelsActivity : ComponentActivity() { viewModelFactory = channelsViewModelFactory, title = stringResource(id = R.string.app_name), isShowingHeader = true, - searchMode = SearchMode.Channels, + searchMode = SearchMode.Messages, onChannelClick = ::openMessages, onSearchMessageItemClick = ::openMessages, onBackPressed = ::finish,