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..55817083494 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.test import io.getstream.chat.android.client.events.AnswerCastedEvent import io.getstream.chat.android.client.events.ChannelDeletedEvent import io.getstream.chat.android.client.events.ChannelHiddenEvent +import io.getstream.chat.android.client.events.ChannelTruncatedEvent import io.getstream.chat.android.client.events.ChannelUpdatedByUserEvent import io.getstream.chat.android.client.events.ChannelUpdatedEvent import io.getstream.chat.android.client.events.ChannelUserBannedEvent @@ -132,6 +133,7 @@ public fun randomChannelVisibleEvent( channelId: String = randomString(), user: User = randomUser(), channel: Channel = randomChannel(), + channelCustom: Map? = null, ): ChannelVisibleEvent = ChannelVisibleEvent( type = EventType.CHANNEL_VISIBLE, createdAt = createdAt, @@ -141,6 +143,7 @@ public fun randomChannelVisibleEvent( channelId = channelId, user = user, channel = channel, + channelCustom = channelCustom, ) public fun randomUserStartWatchingEvent( @@ -199,6 +202,26 @@ public fun randomChannelDeletedEvent( channel = channel, ) +public fun randomChannelTruncatedEvent( + createdAt: Date = Date(), + cid: String = randomCID(), + channelType: String = randomString(), + channelId: String = randomString(), + channel: Channel = randomChannel(), + user: User? = randomUser(), + message: Message? = null, +): ChannelTruncatedEvent = ChannelTruncatedEvent( + type = EventType.CHANNEL_TRUNCATED, + createdAt = createdAt, + rawCreatedAt = streamFormatter.format(createdAt), + cid = cid, + channelType = channelType, + channelId = channelId, + channel = channel, + user = user, + message = message, +) + public fun randomNotificationChannelDeletedEvent( createdAt: Date = Date(), cid: String = randomCID(), @@ -207,6 +230,7 @@ public fun randomNotificationChannelDeletedEvent( channel: Channel = randomChannel(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationChannelDeletedEvent { return NotificationChannelDeletedEvent( type = EventType.NOTIFICATION_CHANNEL_DELETED, @@ -218,6 +242,7 @@ public fun randomNotificationChannelDeletedEvent( channel = channel, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) } @@ -298,6 +323,7 @@ public fun randomNotificationMarkReadEvent( thread: ThreadInfo? = randomThreadInfo(), unreadThreads: Int? = randomInt(), unreadThreadMessages: Int? = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationMarkReadEvent = NotificationMarkReadEvent( type = EventType.NOTIFICATION_MARK_READ, createdAt = createdAt, @@ -313,6 +339,7 @@ public fun randomNotificationMarkReadEvent( thread = thread, unreadThreads = unreadThreads, unreadThreadMessages = unreadThreadMessages, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomNotificationMarkUnreadEvent( @@ -330,6 +357,7 @@ public fun randomNotificationMarkUnreadEvent( threadId: String? = randomString(), unreadThreads: Int = randomInt(), unreadThreadMessages: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationMarkUnreadEvent = NotificationMarkUnreadEvent( type = EventType.NOTIFICATION_MARK_UNREAD, createdAt = createdAt, @@ -347,6 +375,7 @@ public fun randomNotificationMarkUnreadEvent( unreadMessages = unreadMessages, lastReadMessageAt = lastReadMessageAt, lastReadMessageId = lastReadMessageId, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomTypingStopEvent( @@ -418,6 +447,7 @@ public fun randomNotificationAddedToChannelEvent( member: Member = randomMember(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + channelCustom: Map? = null, ): NotificationAddedToChannelEvent { return NotificationAddedToChannelEvent( type = EventType.NOTIFICATION_ADDED_TO_CHANNEL, @@ -430,6 +460,7 @@ public fun randomNotificationAddedToChannelEvent( member = member, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + channelCustom = channelCustom, ) } @@ -442,6 +473,8 @@ public fun randomNotificationMessageNewEvent( message: Message = randomMessage(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, + channelCustom: Map? = null, ): NotificationMessageNewEvent = NotificationMessageNewEvent( type = EventType.NOTIFICATION_MESSAGE_NEW, createdAt = createdAt, @@ -453,6 +486,8 @@ public fun randomNotificationMessageNewEvent( message = message, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, + channelCustom = channelCustom, ) public fun randomMessageUpdateEvent( @@ -480,6 +515,7 @@ public fun randomChannelUpdatedEvent( channelId: String = randomString(), message: Message = randomMessage(), channel: Channel = randomChannel(), + channelCustom: Map? = null, ): ChannelUpdatedEvent { return ChannelUpdatedEvent( type = EventType.CHANNEL_UPDATED, @@ -490,6 +526,7 @@ public fun randomChannelUpdatedEvent( channelId = channelId, message = message, channel = channel, + channelCustom = channelCustom, ) } @@ -501,6 +538,7 @@ public fun randomChannelUpdatedByUserEvent( message: Message = randomMessage(), channel: Channel = randomChannel(), user: User = randomUser(), + channelCustom: Map? = null, ): ChannelUpdatedByUserEvent { return ChannelUpdatedByUserEvent( type = EventType.CHANNEL_UPDATED, @@ -512,6 +550,7 @@ public fun randomChannelUpdatedByUserEvent( message = message, channel = channel, user = user, + channelCustom = channelCustom, ) } @@ -526,6 +565,8 @@ public fun randomNewMessageEvent( totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), channelMessageCount: Int? = positiveRandomInt(), + groupedUnreadChannels: Map? = null, + channelCustom: Map? = null, ): NewMessageEvent { return NewMessageEvent( type = EventType.MESSAGE_NEW, @@ -540,6 +581,8 @@ public fun randomNewMessageEvent( totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, channelMessageCount = channelMessageCount, + groupedUnreadChannels = groupedUnreadChannels, + channelCustom = channelCustom, ) } @@ -551,6 +594,7 @@ public fun randomNotificationChannelTruncatedEvent( channel: Channel = randomChannel(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationChannelTruncatedEvent = NotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, createdAt = createdAt, @@ -561,6 +605,7 @@ public fun randomNotificationChannelTruncatedEvent( channel = channel, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomMarkAllReadEvent( @@ -661,7 +706,7 @@ public fun randomQueryChannelsSpec( filter: FilterObject = NeutralFilterObject, sort: QuerySorter = QuerySortByField(), cids: Set = emptySet(), -): QueryChannelsSpec = QueryChannelsSpec(filter, sort).apply { this.cids = cids } +): QueryChannelsSpec = QueryChannelsSpec(filter, sort, cids) public fun randomNotificationRemovedFromChannelEvent( cid: String = randomCID(), diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index 4b8e6d4e130..8016bfce676 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -164,6 +164,8 @@ public final class io/getstream/chat/android/client/ChatClient { public final fun queryDraftMessages (Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/result/call/Call; public final fun queryDrafts (Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/result/call/Call; public static synthetic fun queryDrafts$default (Lio/getstream/chat/android/client/ChatClient;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/result/call/Call; + public final fun queryGroupedChannels (Ljava/lang/Integer;Ljava/util/Map;ZZ)Lio/getstream/result/call/Call; + public static synthetic fun queryGroupedChannels$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/Integer;Ljava/util/Map;ZZILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryMembers (Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;)Lio/getstream/result/call/Call; public static synthetic fun queryMembers$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryPollVotes (Ljava/lang/String;Lio/getstream/chat/android/models/FilterObject;Ljava/lang/Integer;Ljava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/result/call/Call; @@ -1211,8 +1213,10 @@ public final class io/getstream/chat/android/client/events/ChannelTruncatedEvent } public final class io/getstream/chat/android/client/events/ChannelUpdatedByUserEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1221,10 +1225,11 @@ public final class io/getstream/chat/android/client/events/ChannelUpdatedByUserE public final fun component7 ()Lio/getstream/chat/android/models/User; public final fun component8 ()Lio/getstream/chat/android/models/Channel; public final fun component9 ()Lio/getstream/chat/android/models/Message; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelUpdatedByUserEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; @@ -1238,7 +1243,8 @@ public final class io/getstream/chat/android/client/events/ChannelUpdatedByUserE } public final class io/getstream/chat/android/client/events/ChannelUpdatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; @@ -1247,10 +1253,12 @@ public final class io/getstream/chat/android/client/events/ChannelUpdatedEvent : public final fun component6 ()Ljava/lang/String; public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/Message; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Lio/getstream/chat/android/client/events/ChannelUpdatedEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelUpdatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelUpdatedEvent; + public final fun component9 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;)Lio/getstream/chat/android/client/events/ChannelUpdatedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelUpdatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelUpdatedEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; @@ -1313,7 +1321,8 @@ public final class io/getstream/chat/android/client/events/ChannelUserUnbannedEv } public final class io/getstream/chat/android/client/events/ChannelVisibleEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; @@ -1322,10 +1331,12 @@ public final class io/getstream/chat/android/client/events/ChannelVisibleEvent : public final fun component6 ()Ljava/lang/String; public final fun component7 ()Lio/getstream/chat/android/models/User; public final fun component8 ()Lio/getstream/chat/android/models/Channel; - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/client/events/ChannelVisibleEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelVisibleEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelVisibleEvent; + public final fun component9 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Ljava/util/Map;)Lio/getstream/chat/android/client/events/ChannelVisibleEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/ChannelVisibleEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;Lio/getstream/chat/android/models/Channel;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/ChannelVisibleEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; @@ -1510,6 +1521,10 @@ public abstract interface class io/getstream/chat/android/client/events/HasChann public abstract fun getChannel ()Lio/getstream/chat/android/models/Channel; } +public abstract interface class io/getstream/chat/android/client/events/HasGroupedUnreadChannels { + public abstract fun getGroupedUnreadChannels ()Ljava/util/Map; +} + public abstract interface class io/getstream/chat/android/client/events/HasMember { public abstract fun getMember ()Lio/getstream/chat/android/models/Member; } @@ -1776,13 +1791,15 @@ public final class io/getstream/chat/android/client/events/MessageUpdatedEvent : public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NewMessageEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasWatcherCount, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NewMessageEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasWatcherCount, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()I public final fun component12 ()Ljava/lang/Integer; + public final fun component13 ()Ljava/util/Map; + public final fun component14 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -1791,14 +1808,16 @@ public final class io/getstream/chat/android/client/events/NewMessageEvent : io/ public final fun component7 ()Ljava/lang/String; public final fun component8 ()Lio/getstream/chat/android/models/Message; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;)Lio/getstream/chat/android/client/events/NewMessageEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NewMessageEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NewMessageEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NewMessageEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NewMessageEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NewMessageEvent; public fun equals (Ljava/lang/Object;)Z + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public final fun getChannelMessageCount ()Ljava/lang/Integer; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getMessage ()Lio/getstream/chat/android/models/Message; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I @@ -1811,10 +1830,11 @@ public final class io/getstream/chat/android/client/events/NewMessageEvent : io/ } public final class io/getstream/chat/android/client/events/NotificationAddedToChannelEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasMember, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I + public final fun component11 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1823,10 +1843,11 @@ public final class io/getstream/chat/android/client/events/NotificationAddedToCh public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/Member; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;II)Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Member;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationAddedToChannelEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; @@ -1840,10 +1861,11 @@ public final class io/getstream/chat/android/client/events/NotificationAddedToCh public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationChannelDeletedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationChannelDeletedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1852,14 +1874,15 @@ public final class io/getstream/chat/android/client/events/NotificationChannelDe public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;II)Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelDeletedEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I public fun getType ()Ljava/lang/String; @@ -1885,10 +1908,11 @@ public final class io/getstream/chat/android/client/events/NotificationChannelMu public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; + public final fun component10 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -1897,14 +1921,15 @@ public final class io/getstream/chat/android/client/events/NotificationChannelTr public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;II)Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelTruncatedEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I public fun getType ()Ljava/lang/String; @@ -1992,15 +2017,16 @@ public final class io/getstream/chat/android/client/events/NotificationInvitedEv public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationMarkReadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasUnreadThreadCounts, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationMarkReadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasUnreadThreadCounts, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()Ljava/lang/String; public final fun component11 ()Ljava/lang/String; public final fun component12 ()Lio/getstream/chat/android/models/ThreadInfo; public final fun component13 ()Ljava/lang/Integer; public final fun component14 ()Ljava/lang/Integer; + public final fun component15 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -2009,13 +2035,14 @@ public final class io/getstream/chat/android/client/events/NotificationMarkReadE public final fun component7 ()Ljava/lang/String; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IILjava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkReadEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public final fun getLastReadMessageId ()Ljava/lang/String; public fun getRawCreatedAt ()Ljava/lang/String; public final fun getThread ()Lio/getstream/chat/android/models/ThreadInfo; @@ -2030,9 +2057,9 @@ public final class io/getstream/chat/android/client/events/NotificationMarkReadE public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationMarkUnreadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasUnreadThreadCounts, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationMarkUnreadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasUnreadThreadCounts, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()Ljava/lang/String; @@ -2041,6 +2068,7 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public final fun component14 ()Ljava/lang/String; public final fun component15 ()I public final fun component16 ()Ljava/lang/Integer; + public final fun component17 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -2049,14 +2077,15 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public final fun component7 ()Ljava/lang/String; public final fun component8 ()I public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IIILjava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Integer;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMarkUnreadEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; public final fun getFirstUnreadMessageId ()Ljava/lang/String; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public final fun getLastReadMessageAt ()Ljava/util/Date; public final fun getLastReadMessageId ()Ljava/lang/String; public fun getRawCreatedAt ()Ljava/lang/String; @@ -2072,11 +2101,13 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationMessageNewEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationMessageNewEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I + public final fun component11 ()Ljava/util/Map; + public final fun component12 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -2085,14 +2116,16 @@ public final class io/getstream/chat/android/client/events/NotificationMessageNe public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/Message; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;II)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMessageNewEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMessageNewEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public final fun getChannelCustom ()Ljava/util/Map; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getMessage ()Lio/getstream/chat/android/models/Message; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I @@ -2799,6 +2832,30 @@ public abstract interface class io/getstream/chat/android/client/interceptor/mes public abstract fun prepareMessage (Lio/getstream/chat/android/models/Message;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/models/Message; } +public final class io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Grouped : io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Grouped; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Grouped;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Grouped; + public fun equals (Ljava/lang/Object;)Z + public final fun getGroup ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Standard : io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier { + public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)V + public final fun component1 ()Lio/getstream/chat/android/models/FilterObject; + public final fun component2 ()Lio/getstream/chat/android/models/querysort/QuerySorter; + public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Standard; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Standard;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier$Standard; + public fun equals (Ljava/lang/Object;)Z + public final fun getFilter ()Lio/getstream/chat/android/models/FilterObject; + public final fun getSort ()Lio/getstream/chat/android/models/querysort/QuerySorter; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/client/logger/ChatLogLevel : java/lang/Enum { public static final field ALL Lio/getstream/chat/android/client/logger/ChatLogLevel; public static final field DEBUG Lio/getstream/chat/android/client/logger/ChatLogLevel; @@ -3044,6 +3101,7 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun insertQueryChannels (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun selectBy (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/persistance/repository/ReactionRepository { @@ -3120,7 +3178,7 @@ public final class io/getstream/chat/android/client/persistence/db/dao/MessageRe public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/BlockUserListener, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageForMeListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/DraftMessageListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/LiveLocationListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/PushPreferencesListener, io/getstream/chat/android/client/plugin/listeners/QueryBlockedUsersListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener, io/getstream/chat/android/client/plugin/listeners/UnblockUserListener { +public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/BlockUserListener, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageForMeListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/DraftMessageListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/LiveLocationListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/PushPreferencesListener, io/getstream/chat/android/client/plugin/listeners/QueryBlockedUsersListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener, io/getstream/chat/android/client/plugin/listeners/UnblockUserListener { public fun getErrorHandler ()Lio/getstream/chat/android/client/errorhandler/ErrorHandler; public fun onAttachmentSendRequest (Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onAttachmentSendRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3203,6 +3261,8 @@ public abstract interface class io/getstream/chat/android/client/plugin/Plugin : public fun onQueryDraftMessagesResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryDraftMessagesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryDraftMessagesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryGroupedChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryMembersResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryMembersResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryThreadsPrecondition (Lio/getstream/chat/android/client/api/models/QueryThreadsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3343,6 +3403,10 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener public static synthetic fun onQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener { + public abstract fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryMembersListener { public abstract fun onQueryMembersResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -3452,13 +3516,20 @@ public final class io/getstream/chat/android/client/query/CreateChannelParams { public final class io/getstream/chat/android/client/query/QueryChannelsSpec { public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)V + public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;)V + public synthetic fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/FilterObject; public final fun component2 ()Lio/getstream/chat/android/models/querysort/QuerySorter; + public final fun component3 ()Ljava/util/Set; + public final fun component4 ()Ljava/lang/String; public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; + public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; public static synthetic fun copy$default (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; public fun equals (Ljava/lang/Object;)Z public final fun getCids ()Ljava/util/Set; public final fun getFilter ()Lio/getstream/chat/android/models/FilterObject; + public final fun getGroupKey ()Ljava/lang/String; public final fun getQuerySort ()Lio/getstream/chat/android/models/querysort/QuerySorter; public fun hashCode ()I public final fun setCids (Ljava/util/Set;)V 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..6e5d12b5935 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -184,6 +184,8 @@ import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Flag +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Location @@ -3145,6 +3147,37 @@ internal constructor( } } + /** + * Queries channels grouped into server-defined groups. + * + * Supports per-group request options (`limit`, `next`/`prev` cursors) and returns per-group + * pagination cursors. Pagination (`next` or `prev` on any group) is only allowed when + * exactly one group is requested. + * + * @param limit Default max channels per group when a group does not specify its own limit. + * `null` uses the server default. + * @param groups Optional per-group configuration keyed by group name. `null` returns the + * server-defined default set. + * @param watch Whether to start watching the returned channels for real-time events. + * @param presence Whether to receive presence events for the members of the returned channels. + * + * @return A [Call] containing a [GroupedChannels] with per-group channels and cursors. + */ + @CheckResult + public fun queryGroupedChannels( + limit: Int? = null, + groups: Map? = null, + watch: Boolean = false, + presence: Boolean = false, + ): Call { + return api.queryGroupedChannels(limit = limit, groups = groups, watch = watch, presence = presence) + .doOnResult(userScope) { result -> + plugins.forEach { plugin -> + plugin.onQueryGroupedChannelsResult(result, limit, groups, watch, presence) + } + } + } + /** * Deletes the channel specified by the [channelType] and [channelId]. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt index 7b6225e791f..cdef75e87c8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt @@ -37,6 +37,8 @@ import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Flag +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member @@ -288,6 +290,30 @@ internal interface ChatApi { @CheckResult fun queryChannels(query: QueryChannelsRequest): Call> + /** + * Queries channels grouped into server-defined groups. + * + * Supports per-group request options (limit, next/prev cursors) and returns per-group + * pagination cursors. Pagination (`next` or `prev` on any group) is only allowed when + * exactly one group is requested. + * + * @param limit Default max channels per group when a group does not specify its own limit. + * `null` uses the server default. + * @param groups Optional per-group configuration keyed by group name. `null` returns the + * server-defined default set. + * @param watch Whether to start watching the returned channels for real-time events. + * @param presence Whether to receive presence events for the members of the returned channels. + * + * @return A [Call] containing a [GroupedChannels] with per-group channels and cursors. + */ + @CheckResult + fun queryGroupedChannels( + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ): Call + @CheckResult fun updateUsers(users: List): Call> diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt index da2a06b54c7..c0aca77070d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt @@ -73,6 +73,8 @@ import io.getstream.chat.android.client.api2.model.requests.PollVoteRequest import io.getstream.chat.android.client.api2.model.requests.QueryBannedUsersRequest import io.getstream.chat.android.client.api2.model.requests.QueryDraftMessagesRequest import io.getstream.chat.android.client.api2.model.requests.QueryDraftsRequest +import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsGroupRequest +import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollVotesRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollsRequest import io.getstream.chat.android.client.api2.model.requests.QueryReactionsRequest @@ -126,6 +128,9 @@ import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Flag +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.GuestUser import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member @@ -1315,6 +1320,51 @@ constructor( } } + override fun queryGroupedChannels( + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ): Call { + val body = QueryGroupedChannelsRequest( + limit = limit, + groups = groups?.mapValues { (_, query) -> + QueryGroupedChannelsGroupRequest( + limit = query.limit, + next = query.next, + prev = query.prev, + ) + }, + watch = watch, + presence = presence, + ) + val lazyCall = { + channelApi.queryGroupedChannels( + connectionId = connectionId, + body = body, + ).map { response -> + GroupedChannels( + groups = response.groups.mapValues { entry -> + GroupedChannelsGroup( + groupKey = entry.key, + channels = entry.value.channels.map(::flattenChannel), + unreadChannels = entry.value.unread_channels ?: 0, + next = entry.value.next, + prev = entry.value.prev, + ) + }, + ) + } + } + val isConnectionRequired = watch || presence + return if (isConnectionRequired && connectionId.isBlank()) { + logger.i { "[queryGroupedChannels] postponing because an active connection is required" } + postponeCall(lazyCall) + } else { + lazyCall() + } + } + override fun queryChannel(channelType: String, channelId: String, query: QueryChannelRequest): Call { val request = io.getstream.chat.android.client.api2.model.requests.QueryChannelRequest( state = query.state, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt index 4772aa36a4c..6e7bca2f391 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt @@ -29,6 +29,7 @@ import io.getstream.chat.android.client.api2.model.requests.MarkUnreadRequest import io.getstream.chat.android.client.api2.model.requests.PinnedMessagesRequest import io.getstream.chat.android.client.api2.model.requests.QueryChannelRequest import io.getstream.chat.android.client.api2.model.requests.QueryChannelsRequest +import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsRequest import io.getstream.chat.android.client.api2.model.requests.RejectInviteRequest import io.getstream.chat.android.client.api2.model.requests.RemoveMembersRequest import io.getstream.chat.android.client.api2.model.requests.SendEventRequest @@ -43,6 +44,7 @@ import io.getstream.chat.android.client.api2.model.response.CompletableResponse import io.getstream.chat.android.client.api2.model.response.EventResponse import io.getstream.chat.android.client.api2.model.response.MessagesResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse import io.getstream.chat.android.client.call.RetrofitCall import retrofit2.http.Body import retrofit2.http.DELETE @@ -62,6 +64,21 @@ internal interface ChannelApi { @Body request: QueryChannelsRequest, ): RetrofitCall + /** + * Queries channels grouped into server-defined groups. + * + * Supports per-group request options (limit, next/prev cursors) and returns per-group + * pagination cursors. Pagination is only allowed when exactly one group is requested. + * + * @param connectionId The current connection ID. + * @param body The request body containing the optional per-group configuration map. + */ + @POST("/channels/grouped") + fun queryGroupedChannels( + @Query(QueryParams.CONNECTION_ID) connectionId: String, + @Body body: QueryGroupedChannelsRequest, + ): RetrofitCall + @POST("/channels/{type}/query") fun queryChannel( @Path("type") channelType: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index 95482d5c479..d5039413b8a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -301,6 +301,7 @@ internal class EventMapping( channelId = channel_id, message = message?.toDomain(channel.toChannelInfo()), channel = channel.toDomain(), + channelCustom = channel_custom, ) } @@ -318,6 +319,7 @@ internal class EventMapping( user = user.toDomain(), message = message?.toDomain(channel.toChannelInfo()), channel = channel.toDomain(), + channelCustom = channel_custom, ) } @@ -334,6 +336,7 @@ internal class EventMapping( channelId = channel_id, user = user.toDomain(), channel = channel.toDomain(), + channelCustom = channel_custom, ) } @@ -471,8 +474,8 @@ internal class EventMapping( id = channel_id, type = channel_type, memberCount = channel_member_count ?: 0, - name = channel_custom?.name, - image = channel_custom?.image, + name = channel_custom?.get(CHANNEL_CUSTOM_NAME) as? String, + image = channel_custom?.get(CHANNEL_CUSTOM_IMAGE) as? String, ) NewMessageEvent( type = type, @@ -487,6 +490,8 @@ internal class EventMapping( totalUnreadCount = total_unread_count, unreadChannels = unread_channels, channelMessageCount = channel_message_count, + groupedUnreadChannels = grouped_unread_channels, + channelCustom = channel_custom, ) } @@ -505,6 +510,7 @@ internal class EventMapping( member = member.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + channelCustom = channel_custom, ) } @@ -522,6 +528,7 @@ internal class EventMapping( channel = channel.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -553,6 +560,7 @@ internal class EventMapping( channel = channel.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -625,6 +633,7 @@ internal class EventMapping( unreadThreads = unread_threads, unreadThreadMessages = unread_thread_messages, lastReadMessageId = last_read_message_id, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -640,14 +649,15 @@ internal class EventMapping( cid = cid, channelType = channel_type, channelId = channel_id, - totalUnreadCount = total_unread_count, - unreadChannels = unread_channels, + totalUnreadCount = total_unread_count ?: 0, + unreadChannels = unread_channels ?: 0, firstUnreadMessageId = first_unread_message_id, lastReadMessageId = last_read_message_id, lastReadMessageAt = last_read_at.date, unreadMessages = unread_messages, threadId = thread_id, unreadThreads = unread_threads, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -680,6 +690,8 @@ internal class EventMapping( message = message.toDomain(channel.toChannelInfo()), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, + channelCustom = channel_custom, ) } @@ -1309,4 +1321,9 @@ internal class EventMapping( rawData = rawData, ) } + + companion object { + private const val CHANNEL_CUSTOM_NAME = "name" + private const val CHANNEL_CUSTOM_IMAGE = "image" + } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/DownstreamChannelDto.kt similarity index 77% rename from stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt rename to stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/DownstreamChannelDto.kt index 5a54ee5c804..2cfea86955a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/DownstreamChannelDto.kt @@ -49,18 +49,3 @@ internal data class DownstreamChannelDto( val message_count: Int? = null, val extraData: Map, ) : ExtraDataDto - -/** - * Model holding custom channel fields delivered with `message.new` events. - * - * Note: It is currently relevant only for the [name] and [image] fields. If in the future we need to support more or - * even custom fields, consider changing this DTO (to a Map for example). - * - * @param name The channel name (if available). - * @param image The channel image (if available). - */ -@JsonClass(generateAdapter = true) -internal data class DownstreamChannelCustomDto( - val name: String?, - val image: String?, -) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index 69a14f19241..4bb9842cf54 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -67,6 +67,7 @@ internal data class ChannelUpdatedEventDto( val channel_id: String, val message: DownstreamMessageDto?, val channel: DownstreamChannelDto, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -79,6 +80,7 @@ internal data class ChannelUpdatedByUserEventDto( val user: DownstreamUserDto, val message: DownstreamMessageDto?, val channel: DownstreamChannelDto, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -90,6 +92,7 @@ internal data class ChannelVisibleEventDto( val channel_id: String, val user: DownstreamUserDto, val channel: DownstreamChannelDto, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -189,7 +192,7 @@ internal data class NewMessageEventDto( val user: DownstreamUserDto, val cid: String, val channel_member_count: Int?, - val channel_custom: DownstreamChannelCustomDto?, + val channel_custom: Map?, val channel_type: String, val channel_id: String, val message: DownstreamMessageDto, @@ -197,6 +200,7 @@ internal data class NewMessageEventDto( val total_unread_count: Int = 0, val unread_channels: Int = 0, val channel_message_count: Int? = null, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -224,6 +228,7 @@ internal data class NotificationAddedToChannelEventDto( val member: DownstreamMemberDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -236,6 +241,7 @@ internal data class NotificationChannelDeletedEventDto( val channel: DownstreamChannelDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -255,6 +261,7 @@ internal data class NotificationChannelTruncatedEventDto( val channel: DownstreamChannelDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -307,6 +314,7 @@ internal data class NotificationMarkReadEventDto( val unread_threads: Int? = null, val unread_thread_messages: Int? = null, val last_read_message_id: String?, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -321,10 +329,11 @@ internal data class NotificationMarkUnreadEventDto( val last_read_message_id: String?, val last_read_at: ExactDate, val unread_messages: Int, - val total_unread_count: Int, - val unread_channels: Int, + val total_unread_count: Int? = null, + val unread_channels: Int? = null, val thread_id: String? = null, val unread_threads: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -347,6 +356,8 @@ internal data class NotificationMessageNewEventDto( val message: DownstreamMessageDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, + val channel_custom: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt new file mode 100644 index 00000000000..1c3758c5600 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/QueryGroupedChannelsRequest.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.api2.model.requests + +import com.squareup.moshi.JsonClass + +/** + * Request body for the grouped query channels endpoint (`POST /channels/grouped`). + * + * @param limit Default max channels per group when a group does not specify its own limit. + * `null` uses the server default. + * @param groups Optional per-group configuration keyed by group name. Omitting `groups` returns + * the server-defined default set. Pagination (`next` or `prev` on any group) requires that + * exactly one group is requested. + * @param watch Whether to start watching the returned channels for real-time events. + * @param presence Whether to receive presence events for the members of the returned channels. + */ +@JsonClass(generateAdapter = true) +internal data class QueryGroupedChannelsRequest( + val limit: Int?, + val groups: Map?, + val watch: Boolean, + val presence: Boolean, +) + +/** + * Per-group request options inside a [QueryGroupedChannelsRequest]. + * + * @param limit Max channels for this group. `null` (or `0`) falls back to the request-level limit. + * @param next Cursor for the next page of this group. Mutually exclusive with [prev]. + * @param prev Cursor for the previous page of this group. Mutually exclusive with [next]. + */ +@JsonClass(generateAdapter = true) +internal data class QueryGroupedChannelsGroupRequest( + val limit: Int?, + val next: String?, + val prev: String?, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt new file mode 100644 index 00000000000..ea4a7580b22 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/response/QueryGroupedChannelsResponse.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.api2.model.response + +import com.squareup.moshi.JsonClass + +/** + * Raw API response for the grouped query channels endpoint (`POST /channels/grouped`). + * + * @param groups The channel groups keyed by group name. Each group carries its channels, + * unread count, and optional pagination cursors. + * @param duration The server-reported request duration. + */ +@JsonClass(generateAdapter = true) +internal data class QueryGroupedChannelsResponse( + val groups: Map, + val duration: String, +) + +/** + * A single group within a [QueryGroupedChannelsResponse]. + * + * @param channels The channel responses that belong to this group. + * @param unread_channels The number of channels with unread messages in this group. + * @param next Cursor for the next page of this group, or `null` if there is no further page. + * @param prev Cursor for the previous page of this group, or `null` if there is none. + */ +@JsonClass(generateAdapter = true) +internal data class QueryGroupedChannelsGroup( + val channels: List, + val unread_channels: Int?, + val next: String?, + val prev: String?, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index b534d35ac7a..53db101dd36 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -124,6 +124,23 @@ public sealed interface HasUnreadThreadCounts { public val unreadThreadMessages: Int? } +/** + * Interface that marks a [ChatEvent] as having grouped unread channel counts. + * The [groupedUnreadChannels] map contains per-group unread channel counts keyed by the + * backend-provided group identifier (e.g. `{"direct": 2, "support": 5}`). + * + * The list of events which contain grouped unread channels: + * - message.new + * - notification.message_new + * - notification.mark_read + * - notification.mark_unread + * - notification.channel_deleted + * - notification.channel_truncated + */ +public sealed interface HasGroupedUnreadChannels { + public val groupedUnreadChannels: Map? +} + /** * Triggered when a channel is deleted */ @@ -180,6 +197,7 @@ public data class ChannelUpdatedEvent( override val channelId: String, override val channel: Channel, val message: Message?, + val channelCustom: Map? = null, ) : CidEvent(), HasChannel /** @@ -195,6 +213,7 @@ public data class ChannelUpdatedByUserEvent( override val user: User, override val channel: Channel, val message: Message?, + val channelCustom: Map? = null, ) : CidEvent(), UserEvent, HasChannel /** @@ -209,6 +228,7 @@ public data class ChannelVisibleEvent( override val channelId: String, override val user: User, override val channel: Channel, + val channelCustom: Map? = null, ) : CidEvent(), UserEvent, HasChannel /** @@ -355,7 +375,9 @@ public data class NewMessageEvent( override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, val channelMessageCount: Int?, -) : CidEvent(), UserEvent, HasMessage, HasWatcherCount, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, + val channelCustom: Map? = null, +) : CidEvent(), UserEvent, HasMessage, HasWatcherCount, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when the user is added to the list of channel members @@ -371,6 +393,7 @@ public data class NotificationAddedToChannelEvent( override val member: Member, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, + val channelCustom: Map? = null, ) : CidEvent(), HasChannel, HasMember, HasUnreadCounts /** @@ -386,7 +409,8 @@ public data class NotificationChannelDeletedEvent( override val channel: Channel, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : CidEvent(), HasChannel, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), HasChannel, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when a channel is muted @@ -411,7 +435,8 @@ public data class NotificationChannelTruncatedEvent( override val channel: Channel, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : CidEvent(), HasChannel, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), HasChannel, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when the user accepts an invite @@ -475,7 +500,8 @@ public data class NotificationMarkReadEvent( val thread: ThreadInfo? = null, override val unreadThreads: Int? = null, override val unreadThreadMessages: Int? = null, -) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts, HasGroupedUnreadChannels /** * Triggered when the the user mark as unread a conversation from a particular message @@ -497,7 +523,8 @@ public data class NotificationMarkUnreadEvent( val threadId: String? = null, override val unreadThreads: Int = 0, override val unreadThreadMessages: Int? = null, -) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts, HasGroupedUnreadChannels /** * Triggered when the total count of unread messages (across all channels the user is a member) changes @@ -525,7 +552,9 @@ public data class NotificationMessageNewEvent( override val message: Message, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : CidEvent(), HasChannel, HasMessage, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, + val channelCustom: Map? = null, +) : CidEvent(), HasChannel, HasMessage, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when a message is added to a channel as a thread reply. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt new file mode 100644 index 00000000000..0970ebd081e --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.plugin + +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.query.QueryChannelsSpec +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.querysort.QuerySorter + +/** + * Canonical key for query-channels state held in the `LogicRegistry`, `StateRegistry`, and + * the offline `QueryChannelsRepository`. Each variant represents a distinct way to identify + * a logical channel-list query. + */ +@InternalStreamChatApi +public sealed interface QueryChannelsIdentifier { + + /** + * Standard offset-based queryChannels. Identity is `(filter, sort)`. + */ + public data class Standard( + public val filter: FilterObject, + public val sort: QuerySorter, + ) : QueryChannelsIdentifier + + /** + * Grouped queryChannels. Identity is the stable [group] key returned by the server. + */ + public data class Grouped( + public val group: String, + ) : QueryChannelsIdentifier +} + +/** + * Resolves the identifier for a standard [QueryChannelsRequest]. Grouped identifiers are + * not derivable from a request — they are constructed directly by callers. + */ +@InternalStreamChatApi +public val QueryChannelsRequest.identifier: QueryChannelsIdentifier + get() = QueryChannelsIdentifier.Standard(filter, querySort) + +/** + * Resolves the identifier for a stored [QueryChannelsSpec]. A non-null [QueryChannelsSpec.groupKey] + * means this spec was originally produced by a grouped query. + */ +@InternalStreamChatApi +public val QueryChannelsSpec.identifier: QueryChannelsIdentifier + get() = groupKey?.let { QueryChannelsIdentifier.Grouped(it) } + ?: QueryChannelsIdentifier.Standard(filter, querySort) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt index 54818400314..d134d49e4e7 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/QueryChannelsRepository.kt @@ -33,6 +33,11 @@ public interface QueryChannelsRepository { */ public suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) + /** + * Selects the spec stored under [groupKey]. + */ + public suspend fun selectBy(groupKey: String): QueryChannelsSpec? + /** * Selects by a filter and query sort. * 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..ab8797f6ba9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt @@ -27,6 +27,7 @@ import io.getstream.chat.android.models.querysort.QuerySorter */ internal object NoOpQueryChannelsRepository : QueryChannelsRepository { override suspend fun insertQueryChannels(queryChannelsSpec: QueryChannelsSpec) { /* No-Op */ } + override suspend fun selectBy(groupKey: String): QueryChannelsSpec? = null override suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? = null override suspend fun clear() { /* No-Op */ } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt index 51facf055fd..a14030a827f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt @@ -39,6 +39,7 @@ import io.getstream.chat.android.client.plugin.listeners.PushPreferencesListener import io.getstream.chat.android.client.plugin.listeners.QueryBlockedUsersListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener +import io.getstream.chat.android.client.plugin.listeners.QueryGroupedChannelsListener import io.getstream.chat.android.client.plugin.listeners.QueryMembersListener import io.getstream.chat.android.client.plugin.listeners.QueryThreadsListener import io.getstream.chat.android.client.plugin.listeners.SendAttachmentListener @@ -54,6 +55,8 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.DraftsSort import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message @@ -87,6 +90,7 @@ public interface Plugin : EditMessageListener, QueryChannelListener, QueryChannelsListener, + QueryGroupedChannelsListener, TypingEventListener, HideChannelListener, MarkAllReadListener, @@ -423,6 +427,16 @@ public interface Plugin : /* No-Op */ } + override suspend fun onQueryGroupedChannelsResult( + result: Result, + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ) { + /* No-Op */ + } + override suspend fun onQueryThreadsPrecondition(request: QueryThreadsRequest): Result = Result.Success(Unit) override suspend fun onQueryThreadsRequest(request: QueryThreadsRequest) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt new file mode 100644 index 00000000000..2f55f21494e --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.plugin.listeners + +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery +import io.getstream.result.Result + +/** + * Listener used when querying grouped channels from the backend. + */ +public interface QueryGroupedChannelsListener { + + /** + * Called when the query grouped channels request completes. + * + * @param result The result of the query grouped channels request. + * @param limit The request-level default per-group limit, or `null` for the server default. + * @param groups The per-group request options that were sent, or `null` when the request + * asked for the server-defined default set of groups. + * @param watch Whether watching was requested. + * @param presence Whether presence was requested. + */ + public suspend fun onQueryGroupedChannelsResult( + result: Result, + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ) +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt index d9a60d78450..5ec578cec0f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/query/QueryChannelsSpec.kt @@ -20,9 +20,40 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.querysort.QuerySorter +/** + * Immutable identity of a channels query. + * + * @property filter Filter conditions for the query. + * @property querySort Sort specification for the query. + * @property cids CIDs of channels currently associated with this query. + * @property groupKey Non-null for grouped queries; identifies the group across reconnects. + */ public data class QueryChannelsSpec( val filter: FilterObject, val querySort: QuerySorter, + var cids: Set = emptySet(), + val groupKey: String? = null, ) { - var cids: Set = emptySet() + + /** + * Two-argument constructor preserved for source and binary compatibility with prior versions + * of this class (when [cids] and [groupKey] were mutable body fields). + */ + public constructor( + filter: FilterObject, + querySort: QuerySorter, + ) : this(filter, querySort, emptySet(), null) + + /** + * Two-argument [copy] preserved for binary compatibility. Existing bytecode that referenced the + * pre-refactor 2-arg `copy(filter, querySort)` continues to resolve through this method. + * [cids] and [groupKey] are carried over from the receiver. + * + * Source callers using `spec.copy(filter = x)` resolve here (more applicable overload than the + * auto-generated 4-arg copy), so cids/groupKey are preserved automatically. + */ + public fun copy( + filter: FilterObject = this.filter, + querySort: QuerySorter = this.querySort, + ): QueryChannelsSpec = QueryChannelsSpec(filter, querySort, cids, groupKey) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt new file mode 100644 index 00000000000..4a08bd58e89 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client + +import io.getstream.chat.android.client.chatclient.BaseChatClientTest +import io.getstream.chat.android.client.plugin.Plugin +import io.getstream.chat.android.client.utils.RetroError +import io.getstream.chat.android.client.utils.RetroSuccess +import io.getstream.chat.android.client.utils.verifyNetworkError +import io.getstream.chat.android.client.utils.verifySuccess +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.models.GroupedChannelsGroupQuery +import io.getstream.chat.android.positiveRandomInt +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomInt +import io.getstream.chat.android.randomString +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Tests for the [ChatClient.queryGroupedChannels] endpoint. + */ +internal class ChatClientGroupedChannelsApiTests : BaseChatClientTest() { + + @Test + fun queryGroupedChannelsSuccess() = runTest { + // given + val groupedChannels = GroupedChannels( + groups = mapOf( + randomString().let { key -> + key to GroupedChannelsGroup( + groupKey = key, + channels = listOf(randomChannel()), + unreadChannels = randomInt(), + next = randomString(), + prev = randomString(), + ) + }, + ), + ) + val sut = Fixture() + .givenQueryGroupedChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall()) + .get() + // when + val result = sut.queryGroupedChannels().await() + // then + verifySuccess(result, groupedChannels) + } + + @Test + fun queryGroupedChannelsError() = runTest { + // given + val errorCode = positiveRandomInt() + val sut = Fixture() + .givenQueryGroupedChannelsResult(RetroError(errorCode).toRetrofitCall()) + .get() + // when + val result = sut.queryGroupedChannels().await() + // then + verifyNetworkError(result, errorCode) + } + + @Test + fun `queryGroupedChannels dispatches result to plugin listeners`() = runTest { + // given + val plugin: Plugin = mock() + plugins.add(plugin) + val groupedChannels = GroupedChannels( + groups = mapOf( + "direct" to GroupedChannelsGroup( + groupKey = "direct", + channels = listOf(randomChannel()), + unreadChannels = randomInt(), + next = randomString(), + prev = null, + ), + ), + ) + val sut = Fixture() + .givenQueryGroupedChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall()) + .get() + val groupsParam = mapOf("direct" to GroupedChannelsGroupQuery(limit = 25, next = "cursor")) + // when + sut.queryGroupedChannels( + limit = 30, + groups = groupsParam, + watch = true, + presence = false, + ).await() + // then + verify(plugin).onQueryGroupedChannelsResult( + result = any(), + limit = eq(30), + groups = eq(groupsParam), + watch = eq(true), + presence = eq(false), + ) + } + + internal inner class Fixture { + + fun givenQueryGroupedChannelsResult( + result: io.getstream.result.call.Call, + ) = apply { + whenever(api.queryGroupedChannels(anyOrNull(), anyOrNull(), any(), any())).thenReturn(result) + } + + fun get(): ChatClient = chatClient + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt index d5f08e175b8..b92ef66295e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt @@ -54,6 +54,7 @@ internal fun createChannelTruncatedEventStringJson() = "cid": "channelType:channelId", "user": ${createUserJsonString()}, "channel": ${createChannelJsonString()}, + "grouped_unread_channels": {"direct": 2, "support": 5}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), ) @@ -231,6 +232,7 @@ internal fun createNotificationChannelDeletedEventStringJson() = "cid": "channelType:channelId", "user": ${createUserJsonString()}, "channel": ${createChannelJsonString()}, + "grouped_unread_channels": {"direct": 2, "support": 5}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), ) @@ -300,6 +302,7 @@ internal fun createNotificationMarkReadEventStringJson() = "watcher_count": 3, "total_unread_count": 4, "unread_channels": 5, + "grouped_unread_channels": {"direct": 2, "support": 5}, "channel_last_message_at": "2020-06-29T06:14:28.000Z", "last_read_message_id": "09afcd85-9dbb-4da8-8d85-5a6b4268d755" """.trimIndent(), @@ -316,6 +319,7 @@ internal fun createNotificationMarkUnreadEventStringJson() = "watcher_count": 3, "total_unread_count": 4, "unread_channels": 5, + "grouped_unread_channels": {"direct": 2, "support": 5}, "unread_messages": 1, "first_unread_message_id": "09afcd85-9dbb-4da8-8d85-5a6b4268d755", "last_read_at": "2020-06-29T06:14:28.000Z", @@ -336,6 +340,7 @@ internal fun createNotificationMessageNewEventStringJson() = "watcher_count": 3, "total_unread_count": 4, "unread_channels": 5, + "grouped_unread_channels": {"direct": 2, "support": 5}, "message": ${createMessageJsonString()}, "channel_last_message_at": "2020-06-29T06:14:28.000Z" """.trimIndent(), @@ -558,6 +563,7 @@ internal fun createNewMessageEventStringJson() = "watcher_count": 3, "total_unread_count": 4, "unread_channels": 5, + "grouped_unread_channels": {"direct": 2, "support": 5}, "message": ${createMessageJsonString()}, "channel_last_message_at": "2020-06-29T06:14:28.000Z", "channel_message_count": 1 diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt index c228cf4520a..0bc6998efde 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt @@ -64,6 +64,7 @@ import io.getstream.chat.android.client.api2.model.requests.PartialUpdateUsersRe import io.getstream.chat.android.client.api2.model.requests.PinnedMessagesRequest import io.getstream.chat.android.client.api2.model.requests.PollVoteRequest import io.getstream.chat.android.client.api2.model.requests.QueryBannedUsersRequest +import io.getstream.chat.android.client.api2.model.requests.QueryGroupedChannelsRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollVotesRequest import io.getstream.chat.android.client.api2.model.requests.QueryPollsRequest import io.getstream.chat.android.client.api2.model.requests.QueryReactionsRequest @@ -100,6 +101,7 @@ import io.getstream.chat.android.client.api2.model.response.QueryBannedUsersResp import io.getstream.chat.android.client.api2.model.response.QueryBlockedUsersResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryDraftMessagesResponse +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryMembersResponse import io.getstream.chat.android.client.api2.model.response.QueryPollVotesResponse import io.getstream.chat.android.client.api2.model.response.QueryPollsResponse @@ -1892,6 +1894,35 @@ internal class MoshiChatApiTest { verify(api, times(1)).queryChannels(connectionId, expectedPayload) } + @ParameterizedTest + @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#queryGroupedChannelsInput") + fun testQueryGroupedChannels( + call: RetrofitCall, + expected: KClass<*>, + ) = runTest { + // given + val api = mock() + whenever(api.queryGroupedChannels(any(), any())).doReturn(call) + val sut = Fixture() + .withChannelApi(api) + .get() + // when + val userId = randomString() + val connectionId = randomString() + val limit = randomInt() + sut.setConnection(userId = userId, connectionId = connectionId) + val result = sut.queryGroupedChannels(limit = limit, groups = null, watch = false, presence = false).await() + // then + val expectedPayload = QueryGroupedChannelsRequest( + limit = limit, + groups = null, + watch = false, + presence = false, + ) + result `should be instance of` expected + verify(api, times(1)).queryGroupedChannels(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..5da2c26654a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -49,6 +49,8 @@ import io.getstream.chat.android.client.api2.model.response.QueryBannedUsersResp import io.getstream.chat.android.client.api2.model.response.QueryBlockedUsersResponse import io.getstream.chat.android.client.api2.model.response.QueryChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryDraftMessagesResponse +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsGroup +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse import io.getstream.chat.android.client.api2.model.response.QueryMembersResponse import io.getstream.chat.android.client.api2.model.response.QueryPollVotesResponse import io.getstream.chat.android.client.api2.model.response.QueryPollsResponse @@ -438,6 +440,38 @@ internal object MoshiChatApiTestArguments { Arguments.of(RetroError(statusCode = 500).toRetrofitCall(), Result.Failure::class), ) + @JvmStatic + fun queryGroupedChannelsInput() = listOf( + Arguments.of( + RetroSuccess( + QueryGroupedChannelsResponse( + groups = mapOf( + "all-open" to QueryGroupedChannelsGroup( + channels = listOf( + ChannelResponse( + channel = Mother.randomDownstreamChannelDto(), + hidden = randomBoolean(), + membership = Mother.randomDownstreamMemberDto(), + hide_messages_before = randomDateOrNull(), + draft = randomDownstreamDraftDto(), + ), + ), + unread_channels = randomInt(), + next = null, + prev = null, + ), + ), + duration = "12ms", + ), + ).toRetrofitCall(), + Result.Success::class, + ), + Arguments.of( + RetroError(statusCode = 500).toRetrofitCall(), + Result.Failure::class, + ), + ) + @JvmStatic fun queryChannelInput() = channelResponseArguments() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index b057080ff0f..45bdee02959 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -33,7 +33,6 @@ import io.getstream.chat.android.client.api2.model.dto.ConnectedEventDto import io.getstream.chat.android.client.api2.model.dto.ConnectingEventDto import io.getstream.chat.android.client.api2.model.dto.ConnectionErrorEventDto import io.getstream.chat.android.client.api2.model.dto.DisconnectedEventDto -import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelCustomDto import io.getstream.chat.android.client.api2.model.dto.DraftMessageDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.DraftMessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.ErrorEventDto @@ -186,6 +185,11 @@ internal object EventMappingTestArguments { private val CHANNEL_MEMBER_COUNT = positiveRandomInt() private val CHANNEL_NAME = randomString() private val CHANNEL_IMAGE = randomString() + private val CHANNEL_CUSTOM: Map = mapOf( + "name" to CHANNEL_NAME, + "image" to CHANNEL_IMAGE, + "group" to "vip", + ) private val MESSAGE_ID = randomString() private val MESSAGE = Mother.randomDownstreamMessageDto() private val MESSAGE_WITHOUT_CHANNEL_INFO = MESSAGE.copy(channel = null) @@ -204,6 +208,7 @@ internal object EventMappingTestArguments { private val UNREAD_MESSAGES = positiveRandomInt() private val TOTAL_UNREAD_COUNT = positiveRandomInt() private val UNREAD_CHANNELS = positiveRandomInt() + private val GROUPED_UNREAD_CHANNELS = mapOf("direct" to positiveRandomInt(), "support" to positiveRandomInt()) private val UNREAD_THREADS = positiveRandomInt() private val UNREAD_THREAD_MESSAGES = positiveRandomInt() private val REACTION = Mother.randomDownstreamReactionDto() @@ -225,11 +230,9 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, channel_member_count = CHANNEL_MEMBER_COUNT, - channel_custom = DownstreamChannelCustomDto( - name = CHANNEL_NAME, - image = CHANNEL_IMAGE, - ), + channel_custom = CHANNEL_CUSTOM, message = MESSAGE_WITHOUT_CHANNEL_INFO, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val draftMessageUpdatedDto = DraftMessageUpdatedEventDto( @@ -285,6 +288,7 @@ internal object EventMappingTestArguments { user = USER, message = MESSAGE, channel = CHANNEL, + channel_custom = CHANNEL_CUSTOM, ) private val channelUpdatedDto = ChannelUpdatedEventDto( @@ -295,6 +299,7 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, message = MESSAGE, channel = CHANNEL, + channel_custom = CHANNEL_CUSTOM, ) private val channelUserBannedDto = ChannelUserBannedEventDto( @@ -325,6 +330,7 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, channel = CHANNEL, user = USER, + channel_custom = CHANNEL_CUSTOM, ) private val connectedDto = ConnectedEventDto( @@ -462,6 +468,7 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, channel = CHANNEL, member = MEMBER, + channel_custom = CHANNEL_CUSTOM, ) private val notificationChannelDeletedDto = NotificationChannelDeletedEventDto( @@ -471,6 +478,7 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationChannelMutesUpdatesDto = NotificationChannelMutesUpdatedEventDto( @@ -486,6 +494,7 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationInviteAcceptedDto = NotificationInviteAcceptedEventDto( @@ -528,6 +537,7 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, last_read_message_id = LAST_READ_MESSAGE_ID, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationMarkUnreadDto = NotificationMarkUnreadEventDto( @@ -543,6 +553,7 @@ internal object EventMappingTestArguments { unread_messages = UNREAD_MESSAGES, total_unread_count = TOTAL_UNREAD_COUNT, unread_channels = UNREAD_CHANNELS, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationMessageNewDto = NotificationMessageNewEventDto( @@ -553,6 +564,8 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, message = MESSAGE, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, + channel_custom = CHANNEL_CUSTOM, ) private val notificationThreadMessageNewDto = NotificationThreadMessageNewEventDto( @@ -829,8 +842,8 @@ internal object EventMappingTestArguments { id = newMessageDto.channel_id, type = newMessageDto.channel_type, memberCount = newMessageDto.channel_member_count ?: 0, - name = newMessageDto.channel_custom?.name, - image = newMessageDto.channel_custom?.image, + name = newMessageDto.channel_custom?.get("name") as? String, + image = newMessageDto.channel_custom?.get("image") as? String, ) newMessageDto.message.toDomain(channelInfo) }, @@ -838,6 +851,8 @@ internal object EventMappingTestArguments { totalUnreadCount = newMessageDto.total_unread_count, unreadChannels = newMessageDto.unread_channels, channelMessageCount = newMessageDto.channel_message_count, + groupedUnreadChannels = newMessageDto.grouped_unread_channels, + channelCustom = newMessageDto.channel_custom, ) private val draftMessageUpdatedEvent = DraftMessageUpdatedEvent( @@ -903,6 +918,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { channelUpdatedByUserDto.channel.toDomain() }, + channelCustom = channelUpdatedByUserDto.channel_custom, ) private val channelUpdated = ChannelUpdatedEvent( @@ -916,6 +932,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { channelUpdatedDto.channel.toDomain() }, + channelCustom = channelUpdatedDto.channel_custom, ) private val channelUserBanned = ChannelUserBannedEvent( @@ -949,6 +966,7 @@ internal object EventMappingTestArguments { channelType = channelVisibleDto.channel_type, channel = with(domainMapping) { channelVisibleDto.channel.toDomain() }, channelId = channelVisibleDto.channel_id, + channelCustom = channelVisibleDto.channel_custom, ) private val connected = ConnectedEvent( @@ -1107,6 +1125,7 @@ internal object EventMappingTestArguments { notificationAddedToChannelDto.channel.toDomain() }, member = with(domainMapping) { notificationAddedToChannelDto.member.toDomain() }, + channelCustom = notificationAddedToChannelDto.channel_custom, ) private val notificationChannelDeleted = NotificationChannelDeletedEvent( @@ -1119,6 +1138,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationChannelDeletedDto.channel.toDomain() }, + groupedUnreadChannels = notificationChannelDeletedDto.grouped_unread_channels, ) private val notificationChannelMutesUpdates = NotificationChannelMutesUpdatedEvent( @@ -1138,6 +1158,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationChannelTruncatedDto.channel.toDomain() }, + groupedUnreadChannels = notificationChannelTruncatedDto.grouped_unread_channels, ) private val notificationInviteAccepted = NotificationInviteAcceptedEvent( @@ -1188,6 +1209,7 @@ internal object EventMappingTestArguments { channelType = notificationMarkReadDto.channel_type, channelId = notificationMarkReadDto.channel_id, lastReadMessageId = notificationMarkReadDto.last_read_message_id, + groupedUnreadChannels = notificationMarkReadDto.grouped_unread_channels, ) private val notificationMarkUnread = NotificationMarkUnreadEvent( @@ -1202,8 +1224,9 @@ internal object EventMappingTestArguments { lastReadMessageId = notificationMarkUnreadDto.last_read_message_id, lastReadMessageAt = notificationMarkUnreadDto.last_read_at.date, unreadMessages = notificationMarkUnreadDto.unread_messages, - totalUnreadCount = notificationMarkUnreadDto.total_unread_count, - unreadChannels = notificationMarkUnreadDto.unread_channels, + totalUnreadCount = notificationMarkUnreadDto.total_unread_count ?: 0, + unreadChannels = notificationMarkUnreadDto.unread_channels ?: 0, + groupedUnreadChannels = notificationMarkUnreadDto.grouped_unread_channels, ) private val notificationMessageNew = NotificationMessageNewEvent( @@ -1217,6 +1240,8 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationMessageNewDto.channel.toDomain() }, + groupedUnreadChannels = notificationMessageNewDto.grouped_unread_channels, + channelCustom = notificationMessageNewDto.channel_custom, ) private val notificationThreadMessageNew = NotificationThreadMessageNewEvent( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index dac301fe722..e20926db16f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt @@ -134,6 +134,7 @@ internal object EventArguments { private const val unreadChannels = 5 private const val unreadMessages = 1 private const val totalUnreadCount = 4 + private val groupedUnreadChannels = mapOf("direct" to 2, "support" to 5) private val user = User( id = "bender", role = "user", @@ -403,6 +404,7 @@ internal object EventArguments { channelType = channelType, channelId = channelId, channel = channel, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationChannelTruncatedEvent = NotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, @@ -456,6 +458,7 @@ internal object EventArguments { totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, lastReadMessageId = message.id, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationMarkUnreadEvent = NotificationMarkUnreadEvent( type = EventType.NOTIFICATION_MARK_UNREAD, @@ -471,6 +474,7 @@ internal object EventArguments { firstUnreadMessageId = message.id, lastReadMessageAt = date, lastReadMessageId = parentMessageId, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationMessageNewEvent = NotificationMessageNewEvent( type = EventType.NOTIFICATION_MESSAGE_NEW, @@ -483,6 +487,7 @@ internal object EventArguments { message = message, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationRemovedFromChannelEvent = NotificationRemovedFromChannelEvent( type = EventType.NOTIFICATION_REMOVED_FROM_CHANNEL, @@ -656,6 +661,7 @@ internal object EventArguments { totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, channelMessageCount = 1, + groupedUnreadChannels = groupedUnreadChannels, ) private val newMessageWithoutUnreadCountsEvent = NewMessageEvent( type = EventType.MESSAGE_NEW, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt new file mode 100644 index 00000000000..9bf2010a8ec --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/QueryGroupedChannelsResponseAdapterTest.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2 + +import io.getstream.chat.android.client.api2.model.response.QueryGroupedChannelsResponse +import org.intellij.lang.annotations.Language +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +/** + * Tests for JSON deserialization of [QueryGroupedChannelsResponse] using Moshi. + */ +internal class QueryGroupedChannelsResponseAdapterTest { + private val parser = ParserFactory.createMoshiChatParser() + + @Language("JSON") + private val json = """ + { + "groups": { + "all-open": { + "channels": [ + { + "channel": { + "cid": "messaging:support-123", + "id": "support-123", + "type": "messaging", + "name": "Support", + "image": "https://getstream.imgix.net/images/random_svg/stream_logo.svg", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "frozen": false, + "disabled": false, + "config": { + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "quotes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "polls": false, + "mutes": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "commands": [], + "mark_messages_pending": false + }, + "own_capabilities": [], + "member_count": 0 + }, + "members": [], + "messages": [], + "pinned_messages": [], + "watchers": [], + "watcher_count": 0, + "read": [] + } + ], + "unread_channels": 1 + } + }, + "duration": "12ms" + } + """.trimIndent() + + @Language("JSON") + private val jsonWithoutUnreadCounters = """ + { + "groups": { + "expired": { + "channels": [ + { + "channel": { + "cid": "messaging:support-123", + "id": "support-123", + "type": "messaging", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "frozen": false, + "disabled": false, + "config": { + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "quotes": true, + "uploads": true, + "url_enrichment": true, + "custom_events": false, + "push_notifications": true, + "polls": false, + "mutes": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-02T00:00:00.000Z", + "commands": [], + "mark_messages_pending": false + }, + "own_capabilities": [], + "member_count": 0 + }, + "members": [], + "messages": [], + "pinned_messages": [], + "watchers": [], + "watcher_count": 0, + "read": [] + } + ] + } + }, + "duration": "12ms" + } + """.trimIndent() + + @Test + fun `Deserialize grouped query channels response`() { + val response = parser.fromJson(json, QueryGroupedChannelsResponse::class.java) + + assertEquals("12ms", response.duration) + assertEquals(setOf("all-open"), response.groups.keys) + + val group = response.groups["all-open"]!! + assertEquals(1, group.unread_channels) + assertEquals(1, group.channels.size) + + val channelResponse = group.channels[0] + assertEquals("messaging:support-123", channelResponse.channel.cid) + assertEquals("support-123", channelResponse.channel.id) + assertEquals("messaging", channelResponse.channel.type) + assertEquals("Support", channelResponse.channel.name) + assertEquals("https://getstream.imgix.net/images/random_svg/stream_logo.svg", channelResponse.channel.image) + assertFalse(channelResponse.channel.frozen) + assertEquals(0, channelResponse.channel.member_count) + assertTrue(channelResponse.channel.config.typing_events) + assertTrue(channelResponse.channel.config.read_events) + assertTrue(channelResponse.channel.config.connect_events) + assertTrue(channelResponse.channel.config.search) + assertTrue(channelResponse.channel.config.reactions) + assertTrue(channelResponse.channel.config.replies) + assertTrue(channelResponse.channel.config.uploads) + assertTrue(channelResponse.channel.config.url_enrichment) + assertTrue(channelResponse.channel.config.mutes) + assertEquals("infinite", channelResponse.channel.config.message_retention) + assertEquals(5000, channelResponse.channel.config.max_message_length) + assertEquals(emptyList(), channelResponse.members) + assertEquals(emptyList(), channelResponse.messages) + assertEquals(emptyList(), channelResponse.pinned_messages) + assertEquals(emptyList(), channelResponse.watchers) + assertEquals(0, channelResponse.watcher_count) + assertEquals(emptyList(), channelResponse.read) + } + + @Test + fun `Deserialize default unread counters when missing`() { + val response = parser.fromJson(jsonWithoutUnreadCounters, QueryGroupedChannelsResponse::class.java) + + assertEquals("12ms", response.duration) + assertEquals(setOf("expired"), response.groups.keys) + + val group = response.groups["expired"]!! + assertEquals(null, group.unread_channels) + assertEquals(1, group.channels.size) + assertEquals("messaging:support-123", group.channels[0].channel.cid) + } +} diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 7739de4f09b..99b02083727 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -4896,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;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V public final fun deleteConversation (Lio/getstream/chat/android/models/Channel;)V public final fun dismissChannelAction ()V @@ -4927,8 +4929,21 @@ 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;)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Z)V + public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } 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..e3d1f43444b 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt @@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.compose.state.QueryConfig import io.getstream.chat.android.compose.state.channels.list.ChannelsState import io.getstream.chat.android.compose.state.channels.list.ItemState @@ -37,6 +38,7 @@ import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.User @@ -44,7 +46,9 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.event.handler.chat.factory.groupAwareChatEventHandlerFactory import io.getstream.chat.android.state.extensions.globalStateFlow +import io.getstream.chat.android.state.extensions.initGroupedQueryChannelsAsState import io.getstream.chat.android.state.extensions.queryChannelsAsState import io.getstream.chat.android.state.plugin.state.global.GlobalState import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData @@ -77,10 +81,12 @@ import kotlin.coroutines.cancellation.CancellationException * A state store that represents all the information required to query, filter, show and react to * [Channel] items in a list. * + * Two query modes are supported: + * - **Standard**: classical offset-based channel list driven by a filter and sort. + * - **Grouped**: cursor-paginated list backed by `queryGroupedChannels`. Identified by a + * `groupKey` returned from the server; channels appear under this group automatically. + * * @param chatClient Used to connect to the API. - * @param initialSort The initial sort used for [Channel]s. - * @param initialFilters The current data filter. Users can change this state using [setFilters] to - * impact which data is shown on the UI. * @param channelLimit How many channels we fetch per page. * @param memberLimit How many members are fetched for each channel item when loading channels. * When `null`, the server-side default is used. @@ -93,21 +99,91 @@ 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() { + /** + * Standard channel list constructor. Performs an initial `queryChannels` request driven by + * [initialFilters] / [initialSort]. + */ + public constructor( + chatClient: ChatClient, + initialSort: QuerySorter = QuerySortByField.descByName("last_updated"), + initialFilters: FilterObject? = null, + channelLimit: Int = DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + globalState: Flow = chatClient.globalStateFlow, + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilters, initialSort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = searchDebounceMs, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + globalState = globalState, + ) + + /** + * Grouped channel list constructor. Subscribes to the state identified by [groupKey] without + * issuing a remote call; the state is populated externally by `queryGroupedChannels` responses. + * + * Internally builds a group-aware [ChatEventHandlerFactory] keyed on [groupKey] so that + * `channel.updated` and channel-add events route channels into the correct group based on the + * event's `channel_custom` map. This is not customizable yet; the routing contract is still + * settling. + */ + public constructor( + chatClient: ChatClient, + groupKey: String, + searchDebounceMs: Long = SEARCH_DEBOUNCE_MS, + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + globalState: Flow = chatClient.globalStateFlow, + ) : this( + chatClient = chatClient, + mode = QueryMode.Grouped(groupKey), + channelLimit = DEFAULT_CHANNEL_LIMIT, + memberLimit = null, + messageLimit = null, + chatEventHandlerFactory = groupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = chatClient.clientState, + ), + searchDebounceMs = searchDebounceMs, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + globalState = globalState, + ) + + /** Internal discriminator for the two query modes supported by this ViewModel. */ + internal sealed interface QueryMode { + data class Standard( + val initialFilter: FilterObject?, + val initialSort: QuerySorter, + ) : QueryMode + + data class Grouped(val groupKey: String) : QueryMode + } + private val logger by taggedLogger("Chat:ChannelListVM") /** @@ -132,13 +208,18 @@ public class ChannelListViewModel( /** * State flow that keeps the value of the current [FilterObject] for channels. + * Only meaningful in [QueryMode.Standard]; held but unused in [QueryMode.Grouped]. */ - private val filterFlow: MutableStateFlow = MutableStateFlow(initialFilters) + private val filterFlow: MutableStateFlow = + MutableStateFlow((mode as? QueryMode.Standard)?.initialFilter) /** * State flow that keeps the value of the current [QuerySorter] for channels. + * Only meaningful in [QueryMode.Standard]; held but unused in [QueryMode.Grouped]. */ - private val querySortFlow: MutableStateFlow> = MutableStateFlow(initialSort) + private val querySortFlow: MutableStateFlow> = MutableStateFlow( + (mode as? QueryMode.Standard)?.initialSort ?: QuerySortByField.descByName("last_updated"), + ) /** * The currently active query configuration, stored in a [MutableStateFlow]. It's created using @@ -237,13 +318,28 @@ public class ChannelListViewModel( */ private val searchMessageState: MutableStateFlow = MutableStateFlow(null) - private var lastNextQuery: QueryChannelsRequest? = null + /** + * Emits the effective query input to react to. Standard mode reacts to filter/sort changes + * (via [queryConfigFlow]) in addition to search and refresh; Grouped mode has fixed + * filter/sort, so it only reacts to search and refresh. + * + * Declared before the [init] block: `viewModelScope` uses `Dispatchers.Main.immediate`, which + * starts the launched body synchronously via the unconfined event loop while still inside the + * constructor — so any field referenced from `init()` must be initialised before the init + * block runs, or it is `null` at access time. + */ + private val activeQuery: Flow = when (mode) { + is QueryMode.Standard -> + combine(_searchQuery, queryConfigFlow, refreshFlow) { query, _, _ -> query } + is QueryMode.Grouped -> + combine(_searchQuery, refreshFlow) { query, _ -> query } + } /** * Combines the latest search query and filter to fetch channels and emit them to the UI. */ init { - if (initialFilters == null) { + if (mode is QueryMode.Standard && mode.initialFilter == null) { viewModelScope.launch { val filter = buildDefaultFilter().first() @@ -258,30 +354,69 @@ public class ChannelListViewModel( /** * Makes the initial query to request channels and starts observing state changes. + * + * Three dispatch branches for channel queries: + * - **Standard**: build a standard `QueryChannelsRequest` from filter/sort and issue + * `queryChannelsAsState`. + * - **Grouped + no active channel search**: subscribe to the identifier-keyed state via + * `initGroupedQueryChannelsAsState`. No remote call; `queryGroupedChannels` responses populate + * the state via the listener. + * - **Grouped + active channel search**: fall back to a standalone `queryChannelsAsState` + * using [optimizedChannelSearchFilter]. */ private suspend fun init() { logger.d { "[init] no args" } - combine(_searchQuery, queryConfigFlow, refreshFlow) { query, config, ts -> Triple(query, config, ts) } - .collectLatest { (query, config, ts) -> - logger.i { "[observeInit] ts: $ts, query: $query, config: $config" } - when (query) { - is SearchQuery.Empty, - is SearchQuery.Channels, - -> { - searchScope.coroutineContext.cancelChildren() - observeQueryChannels( - config.copy( - filters = createQueryChannelsFilter(config.filters, query.query), - ), - ) - } - is SearchQuery.Messages -> { - chListScope.coroutineContext.cancelChildren() - handleSearchQuery(query.query) - observeSearchMessages(query.query) + activeQuery.collectLatest { query -> + logger.i { "[observeInit] query: $query" } + when (query) { + is SearchQuery.Empty, + is SearchQuery.Channels, + -> { + searchScope.coroutineContext.cancelChildren() + when (mode) { + is QueryMode.Standard -> + observeQueryChannels(query.query) + is QueryMode.Grouped -> + if (query.query.length >= MIN_CHANNEL_SEARCH_QUERY_LENGTH) { + observeQueryChannels(query.query) + } else { + observeGroupedChannels(mode.groupKey) + } } } + is SearchQuery.Messages -> { + chListScope.coroutineContext.cancelChildren() + handleSearchQuery(query.query) + observeSearchMessages(query.query) + } } + } + } + + /** + * Builds the [QueryChannelsRequest] that backs the channel list for the given [searchQuery]. + * - Standard: wraps the current filter with [createQueryChannelsFilter] to apply the search. + * Returns `null` if no filter has been resolved yet. + * - Grouped: always uses [optimizedChannelSearchFilter] — the caller (`init`) only invokes + * `observeQueryChannels` in Grouped mode when a search query is active. + */ + private fun buildQueryChannelsRequest(searchQuery: String): QueryChannelsRequest? = when (mode) { + is QueryMode.Standard -> { + val baseFilter = filterFlow.value ?: return null + QueryChannelsRequest( + filter = createQueryChannelsFilter(baseFilter, searchQuery), + querySort = querySortFlow.value, + limit = channelLimit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + } + is QueryMode.Grouped -> QueryChannelsRequest( + filter = optimizedChannelSearchFilter(searchQuery), + limit = channelLimit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) } private suspend fun observeSearchMessages(query: String) = runCatching { @@ -403,22 +538,44 @@ public class ChannelListViewModel( } } - @Suppress("LongMethod") - private fun observeQueryChannels(config: QueryConfig) = runCatching { - queryChannelDebouncer.submitSuspendable { - val queryChannelsRequest = QueryChannelsRequest( - filter = config.filters, - querySort = config.querySort, - limit = channelLimit, - messageLimit = messageLimit, - memberLimit = memberLimit, + /** + * Creates a [QueryChannelsState] by issuing a remote queryChannels request built from the + * given [searchQuery] (via [buildQueryChannelsRequest]) and starts collecting from it. + */ + private fun observeQueryChannels(searchQuery: String) = + observeQueryChannelsInternal(tag = "observeQueryChannels") { + val request = buildQueryChannelsRequest(searchQuery) ?: return@observeQueryChannelsInternal null + chatClient.queryChannelsAsState( + request = request, + chatEventHandlerFactory = chatEventHandlerFactory, + coroutineScope = chListScope, ) - logger.d { "[observeQueryChannels] request: $queryChannelsRequest" } - queryChannelsState = chatClient.queryChannelsAsState( - request = queryChannelsRequest, + } + + /** + * Subscribes to the identifier-keyed [QueryChannelsState] for the Grouped variant identified + * by [groupKey], without triggering a remote API call. State is populated externally by + * `queryGroupedChannels` responses routed through the listener. + */ + private fun observeGroupedChannels(groupKey: String) = + observeQueryChannelsInternal(tag = "observeGroupedChannels") { + chatClient.initGroupedQueryChannelsAsState( + identifier = QueryChannelsIdentifier.Grouped(groupKey), chatEventHandlerFactory = chatEventHandlerFactory, coroutineScope = chListScope, ) + } + + /** + * Shared implementation for observing a [QueryChannelsState] from a [createState] producer. + */ + @Suppress("LongMethod") + private fun observeQueryChannelsInternal( + tag: String, + createState: () -> StateFlow?, + ) = runCatching { + queryChannelDebouncer.submitSuspendable { + queryChannelsState = createState() ?: return@submitSuspendable queryChannelsState.filterNotNull().collectLatest { queryChannelsState -> combine( queryChannelsState.channelsStateData, @@ -432,10 +589,10 @@ public class ChannelListViewModel( -> channelsState.copy( isLoading = true, searchQuery = _searchQuery.value, - ).also { logger.d { "[observeQueryChannels] state: Loading" } } + ).also { logger.d { "[$tag] state: Loading" } } ChannelsStateData.OfflineNoResults -> { - logger.v { "[observeQueryChannels] state: OfflineNoResults(channels are empty)" } + logger.v { "[$tag] state: OfflineNoResults(channels are empty)" } channelsState.copy( isLoading = false, channelItems = emptyList(), @@ -444,7 +601,7 @@ public class ChannelListViewModel( } is ChannelsStateData.Result -> { - logger.v { "[observeQueryChannels] state: Result(channels.size: ${state.channels.size})" } + logger.v { "[$tag] state: Result(channels.size: ${state.channels.size})" } channelsState.copy( isLoading = false, channelItems = createChannelItems( @@ -464,8 +621,8 @@ public class ChannelListViewModel( } }.onFailure { when (it is CancellationException) { - true -> logger.v { "[observeQueryChannels] cancelled" } - else -> logger.e { "[observeQueryChannels] failed: $it" } + true -> logger.v { "[$tag] cancelled" } + else -> logger.e { "[$tag] failed: $it" } } } @@ -505,6 +662,12 @@ public class ChannelListViewModel( ) } + private fun optimizedChannelSearchFilter(text: String): FilterObject = + Filters.and( + Filters.autocomplete("name", text), + Filters.`in`("members", user.value?.id.orEmpty()), + ) + /** * Refreshes either channels or search results. */ @@ -548,10 +711,15 @@ public class ChannelListViewModel( * Use this if you need to support runtime filter changes, through custom filters UI. * * Warning: The filter that's applied will override the `initialFilters` set through the constructor. + * No-op in Grouped mode — the group's filter is fixed. * * @param newFilters The new filters to be used as a baseline for filtering channels. */ public fun setFilters(newFilters: FilterObject) { + if (mode is QueryMode.Grouped) { + logger.w { "[setFilters] no-op in Grouped mode (groupKey: ${mode.groupKey})" } + return + } this.filterFlow.tryEmit(value = newFilters) } @@ -559,8 +727,13 @@ public class ChannelListViewModel( * Allows for the change of the query sort used for channel queries. * * Use this if you need to support runtime sort changes, through custom sort UI. + * No-op in Grouped mode — the group's sort is fixed. */ public fun setQuerySort(querySort: QuerySorter) { + if (mode is QueryMode.Grouped) { + logger.w { "[setQuerySort] no-op in Grouped mode (groupKey: ${mode.groupKey})" } + return + } this.querySortFlow.tryEmit(value = querySort) } @@ -585,11 +758,15 @@ public class ChannelListViewModel( private suspend fun loadMoreQueryChannels() { logger.d { "[loadMoreQueryChannels] no args" } - val currentFilter = filterFlow.value - if (currentFilter == null) { - logger.v { "[loadMoreQueryChannels] rejected (no current filter)" } + + // Grouped + no active channel search uses cursor pagination via queryGroupedChannels. + if (mode is QueryMode.Grouped && + _searchQuery.value.query.length < MIN_CHANNEL_SEARCH_QUERY_LENGTH + ) { + loadMoreGroupedChannels(mode.groupKey) return } + val currentQuery = queryChannelsState.value?.nextPageRequest?.value if (currentQuery == null) { logger.v { "[loadMoreQueryChannels] rejected (no current query)" } @@ -603,15 +780,21 @@ public class ChannelListViewModel( logger.v { "[loadMoreQueryChannels] rejected (already loading more)" } return } - val nextQuery = currentQuery.copy( - filter = createQueryChannelsFilter(currentFilter, _searchQuery.value.query), - querySort = querySortFlow.value, - ) - if (lastNextQuery == nextQuery) { - logger.v { "[loadMoreQueryChannels] rejected (same query)" } - return + val nextQuery = when (mode) { + is QueryMode.Standard -> { + val currentFilter = filterFlow.value ?: run { + logger.v { "[loadMoreQueryChannels] rejected (no current filter)" } + return + } + currentQuery.copy( + filter = createQueryChannelsFilter(currentFilter, _searchQuery.value.query), + querySort = querySortFlow.value, + ) + } + // Grouped + active channel search re-issues the existing search request with the + // bumped offset; filter/sort on the request are already correct. + is QueryMode.Grouped -> currentQuery } - lastNextQuery = nextQuery logger.v { "[loadMoreQueryChannels] offset: ${nextQuery.offset}, limit: ${nextQuery.limit}" } channelsState = channelsState.copy(isLoadingMore = true) val result = chatClient.queryChannels(nextQuery).await() @@ -623,6 +806,38 @@ public class ChannelListViewModel( channelsState = channelsState.copy(isLoadingMore = false) } + private suspend fun loadMoreGroupedChannels(groupKey: String) { + logger.d { "[loadMoreGroupedChannels] groupKey: $groupKey" } + val state = queryChannelsState.value + if (state == null) { + logger.v { "[loadMoreGroupedChannels] rejected (no current state)" } + return + } + val cursor = state.nextCursor.value + if (cursor == null) { + logger.v { "[loadMoreGroupedChannels] rejected (no next cursor)" } + return + } + if (channelsState.endOfChannels) { + logger.v { "[loadMoreGroupedChannels] rejected (end of channels)" } + return + } + if (channelsState.isLoadingMore) { + logger.v { "[loadMoreGroupedChannels] rejected (already loading more)" } + return + } + channelsState = channelsState.copy(isLoadingMore = true) + val result = chatClient.queryGroupedChannels( + groups = mapOf(groupKey to GroupedChannelsGroupQuery(next = cursor)), + ).await() + if (result.isSuccess) { + logger.v { "[loadMoreGroupedChannels] completed (listener applied)" } + } else { + logger.e { "[loadMoreGroupedChannels] failed: ${result.errorOrNull()}" } + } + channelsState = channelsState.copy(isLoadingMore = false) + } + /** * Clears the active action if we've chosen [Cancel], otherwise, stores the selected action as * the currently active action, in [activeChannelAction]. @@ -768,7 +983,7 @@ public class ChannelListViewModel( /** * Debounce time for search queries. */ - private const val SEARCH_DEBOUNCE_MS = 300L + internal const val SEARCH_DEBOUNCE_MS = 300L /** * Minimum length of the search query to start searching for channels. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index fbd2c9afd3c..53872b69950 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -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,34 +27,103 @@ import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.event.handler.chat.factory.groupAwareChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.globalStateFlow /** * Builds the factory that contains all the dependencies required for the Channels Screen. * It currently provides the [ChannelListViewModel] using those dependencies. * - * @param chatClient The client used to fetch data. - * @param querySort The sorting order for channels. - * @param filters The base filters used to filter out channels. - * @param channelLimit How many channels we fetch per page. - * @param memberLimit How many members are fetched for each channel item when loading channels. - * When `null`, the server-side default is used. - * @param messageLimit How many messages are fetched for each channel item when loading channels. - * When `null`, the server-side default is used. - * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. - * @param messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. + * Two construction modes are supported: + * - **Standard**: filter + sort drive a classical `queryChannels` request. + * - **Grouped**: a `groupKey` identifies a state populated by `queryGroupedChannels` responses. + * + * Pick the constructor that matches the mode you want — mixing filter/sort with a `groupKey` is + * not supported. */ -public class ChannelViewModelFactory( - private val chatClient: ChatClient = ChatClient.instance(), - private val querySort: QuerySorter = QuerySortByField.descByName("last_updated"), - private val filters: FilterObject? = null, - private val channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, - private val memberLimit: Int? = null, - private val messageLimit: Int? = null, - private val chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), - private val isDraftMessageEnabled: Boolean = false, - private val messageSearchSort: QuerySorter? = null, +@Suppress("LongParameterList") +public class ChannelViewModelFactory internal constructor( + private val chatClient: ChatClient, + private val mode: QueryMode, + private val channelLimit: Int, + private val memberLimit: Int?, + private val messageLimit: Int?, + private val chatEventHandlerFactory: ChatEventHandlerFactory, + private val isDraftMessageEnabled: Boolean, + private val messageSearchSort: QuerySorter?, ) : ViewModelProvider.Factory { + /** + * Standard [ChannelListViewModel] factory. + * + * @param chatClient The client used to fetch data. + * @param querySort The sorting order for channels. + * @param filters The base filters used to filter out channels. + * @param channelLimit How many channels we fetch per page. + * @param memberLimit How many members are fetched for each channel item when loading channels. + * When `null`, the server-side default is used. + * @param messageLimit How many messages are fetched for each channel item when loading channels. + * When `null`, the server-side default is used. + * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Optional sorting for message search results. + * When `null`, the server-side default is used. + */ + @JvmOverloads + public constructor( + chatClient: ChatClient = ChatClient.instance(), + querySort: QuerySorter = QuerySortByField.descByName("last_updated"), + filters: FilterObject? = null, + channelLimit: Int = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit: Int? = null, + messageLimit: Int? = null, + chatEventHandlerFactory: ChatEventHandlerFactory = ChatEventHandlerFactory(chatClient.clientState), + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Standard(initialFilter = filters, initialSort = querySort), + channelLimit = channelLimit, + memberLimit = memberLimit, + messageLimit = messageLimit, + chatEventHandlerFactory = chatEventHandlerFactory, + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + ) + + /** + * Grouped [ChannelListViewModel] factory. Wires the ViewModel to the state identified by + * [groupKey] without firing a remote call; `queryGroupedChannels` responses populate it. + * + * Internally builds a group-aware [ChatEventHandlerFactory] keyed on [groupKey] that routes + * channels based on the inbound event's `channel_custom` map. This is not customizable yet; + * the routing contract is still settling. + * + * @param chatClient The client used to fetch data. + * @param groupKey Identifies the group whose state this ViewModel observes. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Optional sorting for message search results. + */ + @JvmOverloads + public constructor( + chatClient: ChatClient = ChatClient.instance(), + groupKey: String, + isDraftMessageEnabled: Boolean = false, + messageSearchSort: QuerySorter? = null, + ) : this( + chatClient = chatClient, + mode = QueryMode.Grouped(groupKey), + channelLimit = ChannelListViewModel.DEFAULT_CHANNEL_LIMIT, + memberLimit = null, + messageLimit = null, + chatEventHandlerFactory = groupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = chatClient.clientState, + ), + isDraftMessageEnabled = isDraftMessageEnabled, + messageSearchSort = messageSearchSort, + ) + /** * Create a new instance of [ChannelListViewModel] class. */ @@ -64,14 +134,15 @@ public class ChannelViewModelFactory( @Suppress("UNCHECKED_CAST") return ChannelListViewModel( chatClient = chatClient, - initialSort = querySort, - initialFilters = filters, + mode = mode, channelLimit = channelLimit, - messageLimit = messageLimit, memberLimit = memberLimit, + messageLimit = messageLimit, chatEventHandlerFactory = chatEventHandlerFactory, + searchDebounceMs = ChannelListViewModel.SEARCH_DEBOUNCE_MS, isDraftMessageEnabled = isDraftMessageEnabled, messageSearchSort = messageSearchSort, + 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..e8f806dd471 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModelTest.kt @@ -314,39 +314,6 @@ internal class ChannelListViewModelTest { assertEquals("Search query", autoCompleteFilterObject.value) } - @Test - fun `Given channel list in content state and the current user is online When loading more channels Should filter out duplicate calls`() = - runTest { - val nextPageRequest = QueryChannelsRequest( - filter = queryFilter, - querySort = querySort, - offset = 30, - limit = 60, - ) - val chatClient: ChatClient = mock() - val viewModel = Fixture(chatClient) - .givenCurrentUser() - .givenChannelsQuery() - .givenChannelsState( - channelsStateData = ChannelsStateData.Result(listOf(channel1, channel2)), - nextPageRequest = nextPageRequest, - loading = false, - ) - .givenChannelMutes() - .givenIsOffline(false) - .get(this) - - viewModel.loadMore() - viewModel.loadMore() - viewModel.loadMore() - - val captor = argumentCaptor() - verify(chatClient, times(2)).queryChannels(captor.capture()) - assertEquals(2, captor.allValues.size) - assertEquals(0, captor.firstValue.offset) - assertEquals(30, captor.secondValue.offset) - } - @Test fun `Given channel list When setting message search query Should search messages without offset or cursor`() = runTest { @@ -527,6 +494,19 @@ internal class ChannelListViewModelTest { assertEquals(messageSearchSort, sortCaptor.firstValue) } + @Test + fun `Given groupKey ViewModel When initializing Should not call queryChannels`() = + runTest { + val chatClient: ChatClient = mock() + Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState(channelsStateData = ChannelsStateData.Loading, loading = true) + .givenChannelMutes() + .get(this, groupKey = "team-a") + + verify(chatClient, times(0)).queryChannels(any()) + } + private class Fixture( private val chatClient: ChatClient = mock(), private val channelClient: ChannelClient = mock(), @@ -542,8 +522,13 @@ internal class ChannelListViewModelTest { init { val statePlugin: StatePlugin = mock() whenever(globalState.typingChannels) doReturn MutableStateFlow(emptyMap()) - whenever(statePlugin.resolveDependency(eq(StateRegistry::class))) doReturn stateRegistry - whenever(statePlugin.resolveDependency(eq(GlobalState::class))) doReturn globalState + whenever(statePlugin.resolveDependency(any>())).thenAnswer { invocation -> + when (val klass = invocation.getArgument>(0)) { + StateRegistry::class -> stateRegistry + GlobalState::class -> globalState + else -> org.mockito.Mockito.mock(klass.java, org.mockito.Mockito.RETURNS_DEEP_STUBS) + } + } whenever(chatClient.plugins) doReturn listOf(statePlugin) whenever(chatClient.channel(any())) doReturn channelClient whenever(chatClient.channel(any(), any())) doReturn channelClient @@ -607,6 +592,7 @@ internal class ChannelListViewModelTest { loadingMore: Boolean = false, endOfChannels: Boolean = false, nextPageRequest: QueryChannelsRequest? = null, + nextCursor: String? = null, ) = apply { val queryChannelsState: QueryChannelsState = mock { whenever(it.channelsStateData) doReturn MutableStateFlow(channelsStateData) @@ -615,20 +601,45 @@ internal class ChannelListViewModelTest { whenever(it.loadingMore) doReturn MutableStateFlow(loadingMore) whenever(it.endOfChannels) doReturn MutableStateFlow(endOfChannels) whenever(it.nextPageRequest) doReturn MutableStateFlow(nextPageRequest) + whenever(it.nextCursor) doReturn MutableStateFlow(nextCursor) } whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState + whenever( + stateRegistry.queryChannels( + any(), + ), + ) doReturn queryChannelsState } - fun get(testScope: TestScope): ChannelListViewModel { - val channelListViewModel = ChannelListViewModel( - chatClient = chatClient, - initialSort = initialSort, - initialFilters = initialFilters, - isDraftMessageEnabled = false, - chatEventHandlerFactory = ChatEventHandlerFactory(clientState), - messageSearchSort = messageSearchSort, - globalState = MutableStateFlow(globalState), - ) + fun givenChannelsState(queryChannelsState: QueryChannelsState) = apply { + whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState + whenever( + stateRegistry.queryChannels( + any(), + ), + ) doReturn queryChannelsState + } + + fun get(testScope: TestScope, groupKey: String? = null): ChannelListViewModel { + val channelListViewModel = if (groupKey != null) { + ChannelListViewModel( + chatClient = chatClient, + groupKey = groupKey, + isDraftMessageEnabled = false, + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } else { + ChannelListViewModel( + chatClient = chatClient, + initialSort = initialSort, + initialFilters = initialFilters, + isDraftMessageEnabled = false, + chatEventHandlerFactory = ChatEventHandlerFactory(clientState), + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + } testScope.advanceUntilIdle() return channelListViewModel } diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 528c463be21..468b7094a70 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1081,6 +1081,54 @@ public final class io/getstream/chat/android/models/GreaterThanOrEqualsFilterObj public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/models/GroupedChannels { + public fun (Ljava/util/Map;)V + public final fun component1 ()Ljava/util/Map; + public final fun copy (Ljava/util/Map;)Lio/getstream/chat/android/models/GroupedChannels; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannels;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannels; + public fun equals (Ljava/lang/Object;)Z + public final fun getGroups ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/models/GroupedChannelsGroup { + public fun (Ljava/lang/String;Ljava/util/List;ILjava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;ILjava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()I + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/util/List;ILjava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroup;Ljava/lang/String;Ljava/util/List;ILjava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroup; + public fun equals (Ljava/lang/Object;)Z + public final fun getChannels ()Ljava/util/List; + public final fun getGroupKey ()Ljava/lang/String; + public final fun getNext ()Ljava/lang/String; + public final fun getPrev ()Ljava/lang/String; + public final fun getUnreadChannels ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/models/GroupedChannelsGroupQuery { + public fun ()V + public fun (Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Integer; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/models/GroupedChannelsGroupQuery; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/GroupedChannelsGroupQuery;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/GroupedChannelsGroupQuery; + public fun equals (Ljava/lang/Object;)Z + public final fun getLimit ()Ljava/lang/Integer; + public final fun getNext ()Ljava/lang/String; + public final fun getPrev ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/models/GuestUser { public fun (Lio/getstream/chat/android/models/User;Ljava/lang/String;)V public final fun component1 ()Lio/getstream/chat/android/models/User; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt new file mode 100644 index 00000000000..8810c5a32ad --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/GroupedChannels.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.models + +/** + * A grouped channels response returned by [ChatClient.queryGroupedChannels]. + * + * @param groups The channel groups returned by the backend, keyed by group name. + */ +public data class GroupedChannels(public val groups: Map) + +/** + * A channel group returned by [ChatClient.queryGroupedChannels]. + * + * @param groupKey The name of the group. + * @param channels The channels that belong to this group. + * @param unreadChannels The total unread channel count in the group. + * @param next Cursor for the next page of this group, or `null` if there is no further page. + * @param prev Cursor for the previous page of this group, or `null` if there is none. + */ +public data class GroupedChannelsGroup( + public val groupKey: String, + public val channels: List, + public val unreadChannels: Int = 0, + public val next: String? = null, + public val prev: String? = null, +) + +/** + * Per-group request options for [ChatClient.queryGroupedChannels]. + * + * @param limit Max channels for this group. `null` falls back to the request-level limit + * (which, in turn, falls back to the server default when also `null`). + * @param next Cursor for the next page of this group. Mutually exclusive with [prev]. + * @param prev Cursor for the previous page of this group. Mutually exclusive with [next]. + */ +public data class GroupedChannelsGroupQuery( + public val limit: Int? = null, + public val next: String? = null, + public val prev: String? = null, +) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt index e66c9b566a0..1d89b7eca61 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt @@ -88,7 +88,7 @@ import io.getstream.chat.android.offline.repository.domain.user.internal.UserEnt ThreadOrderEntity::class, DraftMessageEntity::class, ], - version = 98, + version = 99, exportSchema = false, ) @TypeConverters( diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt index 728bc67ed12..a49b8808606 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/DatabaseQueryChannelsRepository.kt @@ -16,6 +16,8 @@ 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 @@ -38,14 +40,14 @@ internal class DatabaseQueryChannelsRepository( queryChannelsDao.insert(toEntity(queryChannelsSpec)) } - /** - * Selects by a filter and query sort. - * - * @param filter [FilterObject] - * @param querySort [QuerySorter] - */ + override suspend fun selectBy(groupKey: String): QueryChannelsSpec? { + val identifier = QueryChannelsIdentifier.Grouped(groupKey) + return queryChannelsDao.select(generateId(identifier))?.let(::toModel) + } + override suspend fun selectBy(filter: FilterObject, querySort: QuerySorter): QueryChannelsSpec? { - return queryChannelsDao.select(generateId(filter, querySort))?.let(Companion::toModel) + val identifier = QueryChannelsIdentifier.Standard(filter, querySort) + return queryChannelsDao.select(generateId(identifier))?.let(::toModel) } override suspend fun clear() { @@ -53,22 +55,26 @@ internal class DatabaseQueryChannelsRepository( } private companion object { - private fun generateId(filter: FilterObject, querySort: QuerySorter): String { - return "${filter.hashCode()}-${querySort.toDto().hashCode()}" + private fun generateId(identifier: QueryChannelsIdentifier): String = when (identifier) { + is QueryChannelsIdentifier.Standard -> + "${identifier.filter.hashCode()}-${identifier.sort.toDto().hashCode()}" + is QueryChannelsIdentifier.Grouped -> + "grp:${identifier.group}" } - private fun toEntity(queryChannelsSpec: QueryChannelsSpec): QueryChannelsEntity = - QueryChannelsEntity( - generateId(queryChannelsSpec.filter, queryChannelsSpec.querySort), - queryChannelsSpec.filter, - queryChannelsSpec.querySort, - queryChannelsSpec.cids.toList(), - ) + private fun toEntity(spec: QueryChannelsSpec): QueryChannelsEntity = QueryChannelsEntity( + id = generateId(spec.identifier), + filter = spec.filter, + querySort = spec.querySort, + cids = spec.cids.toList(), + groupKey = spec.groupKey, + ) - private fun toModel(queryChannelsEntity: QueryChannelsEntity): QueryChannelsSpec = - QueryChannelsSpec( - queryChannelsEntity.filter, - queryChannelsEntity.querySort, - ).apply { cids = queryChannelsEntity.cids.toSet() } + private fun toModel(entity: QueryChannelsEntity): QueryChannelsSpec = QueryChannelsSpec( + filter = entity.filter, + querySort = entity.querySort, + cids = entity.cids.toSet(), + groupKey = entity.groupKey, + ) } } diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt index 30b03b70c76..7dc649efc76 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsEntity.kt @@ -29,6 +29,7 @@ internal data class QueryChannelsEntity( val filter: FilterObject, val querySort: QuerySorter, val cids: List, + val groupKey: String? = null, ) internal const val QUERY_CHANNELS_ENTITY_TABLE_NAME = "stream_channel_query" diff --git a/stream-chat-android-state/api/stream-chat-android-state.api b/stream-chat-android-state/api/stream-chat-android-state.api index d745c2150b5..6ae393877ed 100644 --- a/stream-chat-android-state/api/stream-chat-android-state.api +++ b/stream-chat-android-state/api/stream-chat-android-state.api @@ -182,6 +182,7 @@ public abstract interface class io/getstream/chat/android/state/plugin/state/glo public abstract fun getChannelMutes ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getChannelUnreadCount ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getCurrentUserActiveLiveLocations ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getGroupedUnreadChannels ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getMuted ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getThreadDraftMessages ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getTotalUnreadCount ()Lkotlinx/coroutines/flow/StateFlow; @@ -227,6 +228,7 @@ public abstract interface class io/getstream/chat/android/state/plugin/state/que public abstract fun getFilter ()Lio/getstream/chat/android/models/FilterObject; public abstract fun getLoading ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getLoadingMore ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun getNextCursor ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getNextPageRequest ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getRecoveryNeeded ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getSort ()Lio/getstream/chat/android/models/querysort/QuerySorter; diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.kt new file mode 100644 index 00000000000..4ab83fd959a --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/ChannelGroupResolver.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.state.event.handler.chat + +/** + * Resolves the set of group keys carried by an event for the purposes of grouped channel lists + * driven by `queryGroupedChannels`. + * + * Used by [GroupAwareChatEventHandler] to decide whether an incoming event-bearing channel + * should be added to, removed from, or skipped by a query identified by + * [io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier.Grouped]. + * The classification is read from `event.channelCustom` rather than `channel.extraData` because + * the cached/event-bearing channel can lag the server while the event itself carries the + * authoritative custom map. + */ +internal fun interface ChannelGroupResolver { + + /** + * @param channelCustom The `channel_custom` map from the inbound event, or `null` when the + * event does not carry one. + * @param currentGroup The group key of the query asking. Most resolvers will not need this, + * but it allows a single resolver instance to be shared across multiple grouped queries and + * still differentiate behavior per asker (e.g. logging, short-circuiting, per-group rules). + * @return The set of group keys this channel belongs to. A channel can belong to multiple + * groups (e.g. an explicit group plus an `"all"` sentinel). + */ + fun resolve(channelCustom: Map?, currentGroup: String): Set +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt new file mode 100644 index 00000000000..b2f8126ec38 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolver.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.event.handler.chat + +/** + * Default [ChannelGroupResolver] backed by `event.channelCustom`. + * + * Reads an explicit group key from `channelCustom[groupFieldName]` (defaults to `"group"`) + * and always includes the [allGroupKey] sentinel (defaults to `"all"`) so that a designated + * "all channels" grouped query always sees every channel. + * + * @param groupFieldName The key in the event's `channel_custom` map carrying the explicit group + * identifier. + * @param allGroupKey The sentinel group key representing "every channel". Pass `null` to disable + * the implicit sentinel. + */ +internal class DefaultChannelGroupResolver( + private val groupFieldName: String = DEFAULT_GROUP_FIELD_NAME, + private val allGroupKey: String? = DEFAULT_ALL_GROUP_KEY, +) : ChannelGroupResolver { + + override fun resolve(channelCustom: Map?, currentGroup: String): Set = buildSet { + (channelCustom?.get(groupFieldName) as? String)?.let(::add) + allGroupKey?.let(::add) + } + + companion object { + const val DEFAULT_GROUP_FIELD_NAME: String = "group" + const val DEFAULT_ALL_GROUP_KEY: String = "all" + } +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt new file mode 100644 index 00000000000..ab38137bcb4 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandler.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.event.handler.chat + +import io.getstream.chat.android.client.events.ChannelUpdatedByUserEvent +import io.getstream.chat.android.client.events.ChannelUpdatedEvent +import io.getstream.chat.android.client.events.ChannelVisibleEvent +import io.getstream.chat.android.client.events.CidEvent +import io.getstream.chat.android.client.events.HasChannel +import io.getstream.chat.android.client.events.NewMessageEvent +import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent +import io.getstream.chat.android.client.events.NotificationMessageNewEvent +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import kotlinx.coroutines.flow.StateFlow + +/** + * [ChatEventHandler] that routes channels in and out of a grouped channel list based on the + * group key carried by the inbound event's `channel_custom` map. + * + * Intended to be paired with `QueryChannelsIdentifier.Grouped(groupKey)` — one handler instance + * per grouped query. Classification is performed against `event.channelCustom` rather than + * `channel.extraData` because the cached channel can lag the server while the event itself + * carries the authoritative custom map. + * + * For channel-bearing events ([ChannelUpdatedEvent], [ChannelUpdatedByUserEvent]): + * - If [groupKey] is in the resolved set and the channel is not currently in this list, + * [EventHandlingResult.Add]. + * - If [groupKey] is not in the resolved set and the channel IS currently in this list, + * [EventHandlingResult.Remove]. + * - Otherwise [EventHandlingResult.Skip]. + * + * For watch-and-add events ([NotificationAddedToChannelEvent], [NotificationMessageNewEvent], + * [ChannelVisibleEvent]): emits [EventHandlingResult.WatchAndAdd] when the event's + * `channel_custom` says the channel belongs here, otherwise [EventHandlingResult.Skip]. + * + * For [NewMessageEvent]: filtered up-front in [handleCidEvent] using `event.channelCustom` before + * [DefaultChatEventHandler] gets a chance to `Add(cachedChannel)`. Off-group messages produce + * [EventHandlingResult.Skip]. + * + * Member events (`MemberAddedEvent`/`MemberUpdatedEvent`/`MemberRemovedEvent`) and other CID-only + * events do not carry `channel_custom`, so they delegate to [DefaultChatEventHandler] unchanged. + * This means a user added to a channel in another group can briefly appear in this list until the + * follow-up `channel.updated` arrives and [routeByGroup] reclassifies it. + */ +internal class GroupAwareChatEventHandler( + private val groupKey: String, + private val resolver: ChannelGroupResolver, + channels: StateFlow?>, + clientState: ClientState, +) : DefaultChatEventHandler(channels, clientState) { + + override fun handleChannelEvent(event: HasChannel, filter: FilterObject): EventHandlingResult { + return when (event) { + is ChannelUpdatedEvent -> routeByGroup(event.channel, event.channelCustom) + is ChannelUpdatedByUserEvent -> routeByGroup(event.channel, event.channelCustom) + is NotificationAddedToChannelEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) + is NotificationMessageNewEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) + is ChannelVisibleEvent -> watchAndAddIfBelongs(event.cid, event.channelCustom) + // TODO: MemberAddedEvent flicker — when the current user is added to a channel in + // another group, super may Add it here until the follow-up `channel.updated` arrives + // and routeByGroup reclassifies. Verify whether iOS exhibits the same behavior and + // align the contract before exposing the grouped path as stable. + else -> super.handleChannelEvent(event, filter) + } + } + + override fun handleCidEvent( + event: CidEvent, + filter: FilterObject, + cachedChannel: Channel?, + ): EventHandlingResult { + if (event is NewMessageEvent && !belongsHere(event.channelCustom)) { + return EventHandlingResult.Skip + } + return super.handleCidEvent(event, filter, cachedChannel) + } + + /** + * Routes a channel-bearing event to Add / Remove / Skip based on the group resolved from the + * event's `channelCustom` and whether the channel is currently in this grouped list. + */ + private fun routeByGroup(channel: Channel, channelCustom: Map?): EventHandlingResult { + val belongsHere = belongsHere(channelCustom) + val isInList = channels.value?.containsKey(channel.cid) == true + return when { + belongsHere && !isInList -> EventHandlingResult.Add(channel) + !belongsHere && isInList -> EventHandlingResult.Remove(channel.cid) + else -> EventHandlingResult.Skip + } + } + + private fun watchAndAddIfBelongs(cid: String, channelCustom: Map?): EventHandlingResult = + if (belongsHere(channelCustom)) { + EventHandlingResult.WatchAndAdd(cid) + } else { + EventHandlingResult.Skip + } + + private fun belongsHere(channelCustom: Map?): Boolean = + resolver.resolve(channelCustom, groupKey).contains(groupKey) +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt new file mode 100644 index 00000000000..b8167e29416 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/chat/factory/GroupAwareChatEventHandlerFactory.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.event.handler.chat.factory + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.state.event.handler.chat.ChannelGroupResolver +import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler +import io.getstream.chat.android.state.event.handler.chat.DefaultChannelGroupResolver +import io.getstream.chat.android.state.event.handler.chat.GroupAwareChatEventHandler +import kotlinx.coroutines.flow.StateFlow + +/** + * Produces [GroupAwareChatEventHandler] instances for grouped channel lists. + * + * Internal: external consumers should not construct this directly. Compose code reaches it via + * [groupAwareChatEventHandlerFactory], which is the only seam exposed across module boundaries. + */ +internal class GroupAwareChatEventHandlerFactory( + private val groupKey: String, + private val resolver: ChannelGroupResolver = DefaultChannelGroupResolver(), + private val clientState: ClientState = ChatClient.instance().clientState, +) : ChatEventHandlerFactory(clientState) { + + override fun chatEventHandler(channels: StateFlow?>): ChatEventHandler = + GroupAwareChatEventHandler( + groupKey = groupKey, + resolver = resolver, + channels = channels, + clientState = clientState, + ) +} + +/** + * Builds the group-aware [ChatEventHandlerFactory] used to drive grouped channel lists. + * + * Marked as [InternalStreamChatApi] because the underlying handler/factory/resolver classes are + * deliberately hidden — the grouped-channels contract is still settling and we do not yet want to + * commit to a public extension point. Consumers should instantiate a grouped + * `ChannelListViewModel` instead of calling this directly. + */ +@InternalStreamChatApi +public fun groupAwareChatEventHandlerFactory( + groupKey: String, + clientState: ClientState, +): ChatEventHandlerFactory = GroupAwareChatEventHandlerFactory( + groupKey = groupKey, + clientState = clientState, +) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt index dfb15804818..0168d194d63 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.client.events.DraftMessageUpdatedEvent import io.getstream.chat.android.client.events.GlobalUserBannedEvent import io.getstream.chat.android.client.events.GlobalUserUnbannedEvent import io.getstream.chat.android.client.events.HasChannel +import io.getstream.chat.android.client.events.HasGroupedUnreadChannels import io.getstream.chat.android.client.events.HasMessage import io.getstream.chat.android.client.events.HasOwnUser import io.getstream.chat.android.client.events.HasPoll @@ -254,7 +255,14 @@ internal class EventHandlerSequential( logger.v { "[handleChatEvents] batchId: ${batchEvent.id}, batchEvent.size: ${batchEvent.size}" } queryChannelsLogic.parseChatEventResults(batchEvent.sortedEvents).forEach { result -> when (result) { - is EventHandlingResult.Add -> queryChannelsLogic.addChannel(result.channel) + is EventHandlingResult.Add -> { + // Use trackChannel instead of addChannel to avoid overwriting the shared + // per-channel state with a potentially stale DB-cached channel. + // Channel events have already updated per-channel state (e.g., lastMessageAt) + // before this method runs, and refreshChannelsState below will reconcile + // the query map with the live per-channel data. + queryChannelsLogic.trackChannel(result.channel) + } is EventHandlingResult.WatchAndAdd -> queryChannelsLogic.watchAndAddChannel(result.cid) is EventHandlingResult.Remove -> queryChannelsLogic.removeChannel(result.cid) is EventHandlingResult.Skip -> Unit @@ -317,6 +325,7 @@ internal class EventHandlerSequential( var me = clientState.user.value var totalUnreadCount = mutableGlobalState.totalUnreadCount.value var channelUnreadCount = mutableGlobalState.channelUnreadCount.value + var groupedUnreadChannels = mutableGlobalState.groupedUnreadChannels.value var unreadThreadsCount = mutableGlobalState.unreadThreadsCount.value var blockedUserIds = mutableGlobalState.blockedUserIds.value @@ -371,6 +380,9 @@ internal class EventHandlerSequential( (event as? DraftMessageUpdatedEvent)?.let { mutableGlobalState.updateDraftMessage(it.draftMessage) } (event as? DraftMessageDeletedEvent)?.let { mutableGlobalState.removeDraftMessage(it.draftMessage) } (event as? HasUnreadCounts)?.let { modifyValuesFromEvent(it) } + (event as? HasGroupedUnreadChannels)?.groupedUnreadChannels?.let { + groupedUnreadChannels = it + } (event as? HasOwnUser)?.let { modifyValuesFromUser(it.me) } (event as? HasUnreadThreadCounts)?.let { modifyUnreadThreadsCount(it) } (event as? UserUpdatedEvent) @@ -388,6 +400,7 @@ internal class EventHandlerSequential( } mutableGlobalState.setTotalUnreadCount(totalUnreadCount) mutableGlobalState.setChannelUnreadCount(channelUnreadCount) + mutableGlobalState.setGroupedUnreadChannels(groupedUnreadChannels) mutableGlobalState.setUnreadThreadsCount(unreadThreadsCount) mutableGlobalState.setBlockedUserIds(blockedUserIds) logger.v { "[updateGlobalState] completed batchId: ${batchEvent.id}" } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt index 30bcc85bdd1..75db387f396 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/extensions/ChatClient.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.utils.attachment.isImage import io.getstream.chat.android.client.utils.internal.validateCidWithResult import io.getstream.chat.android.client.utils.message.isEphemeral @@ -39,6 +40,7 @@ import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.event.handler.chat.factory.groupAwareChatEventHandlerFactory import io.getstream.chat.android.state.extensions.internal.logic import io.getstream.chat.android.state.extensions.internal.parseAttachmentNameFromUrl import io.getstream.chat.android.state.extensions.internal.requestsAsState @@ -48,6 +50,8 @@ import io.getstream.chat.android.state.plugin.internal.StatePlugin import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.global.GlobalState +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelStateFlow import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState import io.getstream.log.StreamLog @@ -65,10 +69,12 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -143,6 +149,36 @@ public fun ChatClient.queryChannelsAsState( } } +/** + * Creates a [QueryChannelsState] for the given grouped [identifier] without triggering a remote + * queryChannels API call. Channels cached under the identifier's DB key are loaded optimistically + * so the UI can render immediately while the next `queryGroupedChannels` response populates the + * state via the listener. + * + * Only [QueryChannelsIdentifier.Grouped] is accepted — the standard offset-paginated path is + * served by [queryChannelsAsState] instead. + * + * @param identifier The grouped query's identifier whose state should be initialized. + * @param chatEventHandlerFactory The factory used to create the [ChatEventHandler] that routes + * events into this grouped list. Defaults to a group-aware factory keyed on [identifier]. + * @param coroutineScope The [CoroutineScope] used for executing the request. + * + * @return A StateFlow that emits null until the user is connected, then emits the [QueryChannelsState] for the identifier. + */ +@InternalStreamChatApi +@JvmOverloads +public fun ChatClient.initGroupedQueryChannelsAsState( + identifier: QueryChannelsIdentifier.Grouped, + chatEventHandlerFactory: ChatEventHandlerFactory = + groupAwareChatEventHandlerFactory(groupKey = identifier.group, clientState = clientState), + coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), +): StateFlow { + StreamLog.d(TAG) { "[initGroupedQueryChannelsAsState] identifier: $identifier" } + return getStateOrNull(coroutineScope) { + requestsAsState(coroutineScope).initGroupedQueryChannelsState(identifier, chatEventHandlerFactory) + } +} + /** * Performs [ChatClient.queryChannel] with watch = true under the hood and returns [ChannelState] associated with the query. * The [ChannelState] cannot be created before connecting the user therefore, the method returns a StateFlow @@ -161,9 +197,22 @@ public fun ChatClient.watchChannelAsState( coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), ): StateFlow { StreamLog.i(TAG) { "[watchChannelAsState] cid: $cid, messageLimit: $messageLimit" } - return getStateOrNull(coroutineScope) { + val record = WatchedChannelRecord(cid) + // Register the record in a short-lived coroutine that waits for initialization. + // IMPORTANT: do NOT capture record in the getStateOrNull lambda — that lambda + // is kept alive by the coroutineScope (ChatClient-scoped) and would prevent + // the record from being GC'd when the caller drops the returned flow. + // This launch completes after registration, releasing its lambda captures. + coroutineScope.launch { + runCatching { + clientState.initializationState.first { it == InitializationState.COMPLETE } + state.trackWatchedChannel(record) + } + } + val flow = getStateOrNull(coroutineScope) { requestsAsState(coroutineScope).watchChannel(cid, messageLimit, stateConfig.userPresence) } + return WatchedChannelStateFlow(flow, record) } /** diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt index 1e0c093f19f..d5f6a5ed595 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/model/querychannels/pagination/internal/Mapper.kt @@ -17,8 +17,21 @@ package io.getstream.chat.android.state.model.querychannels.pagination.internal import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest +/** + * Converts a [QueryChannelsRequest] to an [AnyChannelPaginationRequest] for offline cache lookups. + */ +internal fun QueryChannelsRequest.toOfflinePaginationRequest(): AnyChannelPaginationRequest = + QueryChannelsPaginationRequest( + sort = querySort, + channelLimit = limit, + channelOffset = offset, + messageLimit = messageLimit ?: 10, + memberLimit = memberLimit ?: 30, + ).toAnyChannelPaginationRequest() + internal fun QueryChannelsPaginationRequest.toAnyChannelPaginationRequest(): AnyChannelPaginationRequest { val originalRequest = this return AnyChannelPaginationRequest().apply { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt index 4ae3222377a..80a983f7f7b 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/internal/StatePlugin.kt @@ -36,6 +36,7 @@ import io.getstream.chat.android.client.plugin.listeners.PushPreferencesListener import io.getstream.chat.android.client.plugin.listeners.QueryBlockedUsersListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelListener import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener +import io.getstream.chat.android.client.plugin.listeners.QueryGroupedChannelsListener import io.getstream.chat.android.client.plugin.listeners.QueryMembersListener import io.getstream.chat.android.client.plugin.listeners.QueryThreadsListener import io.getstream.chat.android.client.plugin.listeners.SendAttachmentListener @@ -67,6 +68,7 @@ import io.getstream.chat.android.state.plugin.listener.internal.PushPreferencesL import io.getstream.chat.android.state.plugin.listener.internal.QueryBlockedUsersListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryChannelListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryChannelsListenerState +import io.getstream.chat.android.state.plugin.listener.internal.QueryGroupedChannelsListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryMembersListenerState import io.getstream.chat.android.state.plugin.listener.internal.QueryThreadsListenerState import io.getstream.chat.android.state.plugin.listener.internal.SendAttachmentListenerState @@ -114,6 +116,7 @@ public class StatePlugin internal constructor( ) : Plugin, QueryMembersListener by QueryMembersListenerState(logic), QueryChannelsListener by QueryChannelsListenerState(logic, queryingChannelsFree), + QueryGroupedChannelsListener by QueryGroupedChannelsListenerState(logic, mutableGlobalState), QueryChannelListener by QueryChannelListenerState(logic), ThreadQueryListener by ThreadQueryListenerState(logic, repositoryFacade), ChannelMarkReadListener by ChannelMarkReadListenerState(stateRegistry), diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt index 4ee12d6c2bc..e667820d2ca 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryChannelsListenerState.kt @@ -18,10 +18,8 @@ package io.getstream.chat.android.state.plugin.listener.internal import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener -import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.state.model.querychannels.pagination.internal.QueryChannelsPaginationRequest -import io.getstream.chat.android.state.model.querychannels.pagination.internal.toAnyChannelPaginationRequest +import io.getstream.chat.android.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry import io.getstream.result.Result import kotlinx.coroutines.flow.MutableStateFlow @@ -52,7 +50,7 @@ internal class QueryChannelsListenerState( queryingChannelsFree.value = false logicProvider.queryChannels(request).run { setCurrentRequest(request) - queryOffline(request.toPagination()) + queryOffline(request.toOfflinePaginationRequest()) } } @@ -60,16 +58,4 @@ internal class QueryChannelsListenerState( logicProvider.queryChannels(request).onQueryChannelsResult(result, request) queryingChannelsFree.value = true } - - private companion object { - - private fun QueryChannelsRequest.toPagination(): AnyChannelPaginationRequest = - QueryChannelsPaginationRequest( - sort = querySort, - channelLimit = limit, - channelOffset = offset, - messageLimit = messageLimit ?: 10, - memberLimit = memberLimit ?: 30, - ).toAnyChannelPaginationRequest() - } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt new file mode 100644 index 00000000000..8c325c43d30 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt @@ -0,0 +1,57 @@ +/* + * 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.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.plugin.listeners.QueryGroupedChannelsListener +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery +import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.result.Result + +internal class QueryGroupedChannelsListenerState( + private val logic: LogicRegistry, + private val globalState: MutableGlobalState, +) : QueryGroupedChannelsListener { + + override suspend fun onQueryGroupedChannelsResult( + result: Result, + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ) { + if (result !is Result.Success) return + + // The request may include any subset of groups (pagination, custom per-group limits, + // or the default set). Always merge the returned counts into the existing map so groups + // not present in this response retain their previous counts. + val returnedUnreadCounts = result.value.groups.mapValues { (_, group) -> group.unreadChannels } + val merged = globalState.groupedUnreadChannels.value + returnedUnreadCounts + globalState.setGroupedUnreadChannels(merged) + + // Route each returned group's channels into the per-group state. + result.value.groups.forEach { (key, group) -> + // A request without a `next` cursor for this key (or no per-group query at all) is + // a first-page request → replace channels. With a `next` cursor → paginated → append. + val isFirstPage = groups?.get(key)?.next == null + logic.queryChannels(QueryChannelsIdentifier.Grouped(key)) + .applyGroupedResult(group, isFirstPage = isFirstPage) + } + } +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt index b346340cb8d..b5b68c0c457 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt @@ -21,6 +21,8 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelStateLogicProvider import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.internal.state.plugin.identifier import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.Channel @@ -66,17 +68,20 @@ internal class LogicRegistry internal constructor( private val now: () -> Long, ) : ChannelStateLogicProvider { - private val queryChannels: ConcurrentHashMap>, QueryChannelsLogic> = + private val queryChannels: ConcurrentHashMap = ConcurrentHashMap() private val channels: ConcurrentHashMap, ChannelLogic> = ConcurrentHashMap() private val queryThreads: ConcurrentHashMap?>, QueryThreadsLogic> = ConcurrentHashMap() private val threads: ConcurrentHashMap = ConcurrentHashMap() - internal fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsLogic { - return queryChannels.getOrPut(filter to sort) { + internal fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsLogic = + queryChannels(QueryChannelsIdentifier.Standard(filter, sort)) + + internal fun queryChannels(identifier: QueryChannelsIdentifier): QueryChannelsLogic { + return queryChannels.getOrPut(identifier) { val queryChannelsStateLogic = QueryChannelsStateLogic( - mutableState = stateRegistry.queryChannels(filter, sort).toMutableState(), + mutableState = stateRegistry.queryChannels(identifier).toMutableState(), stateRegistry = stateRegistry, logicRegistry = this, coroutineScope = coroutineScope, @@ -90,8 +95,7 @@ internal class LogicRegistry internal constructor( ) QueryChannelsLogic( - filter, - sort, + identifier, client, queryChannelsStateLogic, queryChannelsDatabaseLogic, @@ -101,7 +105,7 @@ internal class LogicRegistry internal constructor( /** Returns [QueryChannelsLogic] accordingly to [QueryChannelsRequest]. */ internal fun queryChannels(queryChannelsRequest: QueryChannelsRequest): QueryChannelsLogic = - queryChannels(queryChannelsRequest.filter, queryChannelsRequest.querySort) + queryChannels(queryChannelsRequest.identifier) /** Returns [ChannelLogic] by channelType and channelId combination. */ fun channel(channelType: String, channelId: String): ChannelLogic { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt index d35b7e083d0..8b3fd498ba5 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsDatabaseLogic.kt @@ -51,7 +51,12 @@ internal class QueryChannelsDatabaseLogic( queryChannelsSpec: QueryChannelsSpec?, ): List? { val cachedSpec = queryChannelsSpec?.let { - queryChannelsRepository.selectBy(it.filter, it.querySort) + val groupKey = it.groupKey + if (groupKey != null) { + queryChannelsRepository.selectBy(groupKey) + } else { + queryChannelsRepository.selectBy(it.filter, it.querySort) + } } return if (cachedSpec != null) { // Spec is present in DB, fetch channels according to it diff --git a/stream-chat-android-state/src/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..92d797e58c6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -20,14 +20,16 @@ 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 +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.User -import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.log.taggedLogger import io.getstream.result.Result import kotlinx.coroutines.flow.StateFlow @@ -37,8 +39,7 @@ private const val CHANNEL_LIMIT = 30 @Suppress("TooManyFunctions") internal class QueryChannelsLogic( - private val filter: FilterObject, - private val sort: QuerySorter, + private val identifier: QueryChannelsIdentifier, private val client: ChatClient, private val queryChannelsStateLogic: QueryChannelsStateLogic, private val queryChannelsDatabaseLogic: QueryChannelsDatabaseLogic, @@ -46,6 +47,58 @@ internal class QueryChannelsLogic( private val logger by taggedLogger("Chat:QueryChannelsLogic") + /** + * Sets the current request and optimistically loads any cached channels for the given + * [request] from the local database. The cached channels are added to the in-memory state. + * No remote API call is made. + */ + internal suspend fun loadOfflineChannels(request: QueryChannelsRequest) { + setCurrentRequest(request) + val offlineChannels = fetchChannelsFromCache(request.toOfflinePaginationRequest(), queryChannelsDatabaseLogic) + // fetchChannelsFromCache suspends for DB I/O. During that suspension, fresh data may have + // landed via another path. Check after the DB read to avoid appending stale offline data on + // top of fresh channels. + val existing = queryChannelsStateLogic.getChannels() + if (!existing.isNullOrEmpty()) { + logger.d { "[loadOfflineChannels] skipped (channels already populated: ${existing.size})" } + return + } + if (offlineChannels != null) { + queryChannelsStateLogic.addChannelsState(offlineChannels) + } + // Ensure channels map is non-null (empty if no cache) and loading is reset, so + // channelsStateData transitions to OfflineNoResults instead of staying in Loading. + queryChannelsStateLogic.initializeChannelsIfNeeded() + queryChannelsStateLogic.setLoadingFirstPage(false) + } + + /** + * Grouped-only offline cache read. Called from the Grouped init flow. Standard's + * [loadOfflineChannels] is untouched. + * + * Reads channels stored under the stable identifier-derived id and seeds in-memory state, + * guarding against the case where a concurrent [applyGroupedResult] call has already populated + * the state with fresh data. + */ + internal suspend fun loadOfflineGroupedChannels() { + if (identifier !is QueryChannelsIdentifier.Grouped) { + logger.w { "[loadOfflineGroupedChannels] rejected (non-Grouped identifier: $identifier)" } + return + } + val pagination = AnyChannelPaginationRequest().apply { + channelOffset = 0 + channelLimit = CHANNEL_LIMIT + } + val cachedChannels = fetchChannelsFromCache(pagination, queryChannelsDatabaseLogic) + val existing = queryChannelsStateLogic.getChannels() + if (existing.isNullOrEmpty() && !cachedChannels.isNullOrEmpty()) { + logger.d { "[loadOfflineGroupedChannels] showing ${cachedChannels.size} cached channels" } + queryChannelsStateLogic.addChannelsState(cachedChannels) + } + queryChannelsStateLogic.initializeChannelsIfNeeded() + queryChannelsStateLogic.setLoadingFirstPage(false) + } + internal suspend fun queryOffline(pagination: AnyChannelPaginationRequest) { if (queryChannelsStateLogic.isLoading()) { logger.i { "[queryOffline] another query channels request is in progress. Ignoring this request." } @@ -80,7 +133,14 @@ internal class QueryChannelsLogic( queryChannelsStateLogic.setCurrentRequest(request) } - internal fun filter(): FilterObject = filter + internal fun filter(): FilterObject = when (identifier) { + is QueryChannelsIdentifier.Standard -> identifier.filter + is QueryChannelsIdentifier.Grouped -> queryChannelsStateLogic.getState().filter + } + + internal fun groupKey(): String? = (identifier as? QueryChannelsIdentifier.Grouped)?.group + + internal fun currentRequest(): QueryChannelsRequest? = queryChannelsStateLogic.getState().currentRequest.value internal fun recoveryNeeded(): StateFlow { return queryChannelsStateLogic.getState().recoveryNeeded @@ -113,6 +173,16 @@ internal class QueryChannelsLogic( addChannels(listOf(channel)) } + /** + * Registers [channel] in this query's tracking without updating the shared per-channel + * state. Use this during event handling where per-channel state is already authoritative. + * A subsequent [refreshChannelState] / [refreshChannelsState] call will reconcile the + * query map with the live per-channel state. + */ + internal fun trackChannel(channel: Channel) { + queryChannelsStateLogic.trackChannel(channel) + } + /** * Calls watch channel and adds result to the query. * @@ -133,6 +203,44 @@ internal class QueryChannelsLogic( } } + /** + * Applies a [GroupedChannelsGroup] response payload to this query's state. + * Replaces channels on the first page, appends on subsequent pages. + * Updates the next-page cursor and persists fresh data to the local database. + */ + internal suspend fun applyGroupedResult(group: GroupedChannelsGroup, isFirstPage: Boolean) { + if (identifier !is QueryChannelsIdentifier.Grouped) { + logger.w { "[applyGroupedResult] rejected (non-Grouped identifier: $identifier)" } + return + } + val channels = group.channels + logger.d { + "[applyGroupedResult] channels.size: ${channels.size}, isFirstPage: $isFirstPage, " + + "next: ${group.next}" + } + + if (isFirstPage) { + val existing = queryChannelsStateLogic.getChannels() + if (!existing.isNullOrEmpty()) { + queryChannelsStateLogic.removeChannels(existing.keys) + } + queryChannelsStateLogic.setCids(emptySet()) + } + + queryChannelsStateLogic.addChannelsState(channels) + queryChannelsStateLogic.setNextCursor(group.next) + queryChannelsStateLogic.setEndOfChannels(group.next == null) + queryChannelsStateLogic.setLoadingFirstPage(false) + queryChannelsStateLogic.setLoadingMore(false) + queryChannelsStateLogic.setRecoveryNeeded(false) + + // Persist + queryChannelsDatabaseLogic.insertQueryChannels(queryChannelsStateLogic.getQuerySpecs()) + val channelConfigs = channels.map { ChannelConfig(it.type, it.config) } + queryChannelsDatabaseLogic.insertChannelConfigs(channelConfigs) + queryChannelsDatabaseLogic.storeStateForChannels(channels.toSet()) + } + suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { logger.d { "[onQueryChannelsResult] result.isSuccess: ${result is Result.Success}, request: $request" } onOnlineQueryResult(result, request) @@ -148,27 +256,36 @@ internal class QueryChannelsLogic( } /** - * Runs [QueryChannelsRequest] which is querying the first page. + * Runs [QueryChannelsRequest] which is querying the first page. No-op for grouped identifiers — + * the grouped path uses `queryGroupedChannels` instead. */ internal suspend fun queryFirstPage(): Result> { logger.d { "[queryFirstPage] no args" } - val currentRequest = queryChannelsStateLogic.getState().currentRequest.value - val messageLimit = currentRequest?.messageLimit - val memberLimit = currentRequest?.memberLimit - val request = QueryChannelsRequest( - filter = filter, - offset = INITIAL_CHANNEL_OFFSET, - limit = CHANNEL_LIMIT, - querySort = sort, - messageLimit = messageLimit, - memberLimit = memberLimit, - ) - - queryChannelsStateLogic.setCurrentRequest(request) - - return client.queryChannelsInternal(request) - .await() - .also { onQueryChannelsResult(it, request) } + return when (identifier) { + is QueryChannelsIdentifier.Standard -> { + val currentRequest = queryChannelsStateLogic.getState().currentRequest.value + val messageLimit = currentRequest?.messageLimit + val memberLimit = currentRequest?.memberLimit + val request = QueryChannelsRequest( + filter = identifier.filter, + offset = INITIAL_CHANNEL_OFFSET, + limit = CHANNEL_LIMIT, + querySort = identifier.sort, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) + + queryChannelsStateLogic.setCurrentRequest(request) + + client.queryChannelsInternal(request) + .await() + .also { onQueryChannelsResult(it, request) } + } + is QueryChannelsIdentifier.Grouped -> { + logger.v { "[queryFirstPage] no-op for Grouped identifier" } + Result.Success(emptyList()) + } + } } private suspend fun onOnlineQueryResult(result: Result>, request: QueryChannelsRequest) { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index f1d7b9a1812..48b44045da6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -124,6 +124,14 @@ internal class QueryChannelsStateLogic( mutableState.setChannelsOffset(offset) } + internal fun setNextCursor(cursor: String?) { + mutableState.setNextCursor(cursor) + } + + internal fun setCids(cids: Set) { + mutableState.setCids(cids) + } + /** * Increments the channels offset. * @@ -142,7 +150,7 @@ internal class QueryChannelsStateLogic( * @param channels List. */ internal suspend fun addChannelsState(channels: List) { - mutableState.queryChannelsSpec.cids += channels.map { it.cid } + mutableState.setCids(mutableState.queryChannelsSpec.cids + channels.map { it.cid }) val existingChannels = mutableState.rawChannels ?: emptyMap() mutableState.setChannels( existingChannels + @@ -197,7 +205,7 @@ internal class QueryChannelsStateLogic( logger.w { "[removeChannels] rejected (existingChannels is null)" } return } - mutableState.queryChannelsSpec.cids = mutableState.queryChannelsSpec.cids - cidSet + mutableState.setCids(mutableState.queryChannelsSpec.cids - cidSet) mutableState.setChannels(existingChannels - cidSet) } @@ -211,6 +219,24 @@ internal class QueryChannelsStateLogic( } } + /** + * Registers the given [channel] in this query's tracking (CID spec + channel map) + * **without** updating the shared per-channel [ChannelState]. + * + * Use this instead of [addChannelsState] when the channel is already active and its + * per-channel state may contain fresher data than the provided [channel] object + * (e.g., during event handling where the channel event handler has already updated + * `lastMessageAt` but the DB-cached channel still has the old value). + * + * A subsequent [refreshChannels] call will pull the authoritative per-channel state + * into the query map. + */ + internal fun trackChannel(channel: Channel) { + mutableState.setCids(mutableState.queryChannelsSpec.cids + channel.cid) + val existingChannels = mutableState.rawChannels ?: emptyMap() + mutableState.setChannels(existingChannels + (channel.cid to channel)) + } + /** * Refreshes multiple channels in this query. * Note that it retrieves the data from the current [ChannelState] object. diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt index 5f55d9e7797..9bf8d1f6ec5 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/StateRegistry.kt @@ -20,6 +20,8 @@ import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.events.ChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Location @@ -31,6 +33,7 @@ import io.getstream.chat.android.state.plugin.config.MessageLimitConfig import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.channel.thread.internal.ThreadMutableState +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState @@ -40,6 +43,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.StateFlow +import java.lang.ref.WeakReference import java.util.concurrent.ConcurrentHashMap /** @@ -65,13 +69,15 @@ 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> = ConcurrentHashMap() private val threads: ConcurrentHashMap = ConcurrentHashMap() + private val watchedChannelRecords = mutableListOf>() + /** * Returns [QueryChannelsState] associated with particular [filter] and [sort]. * @@ -80,9 +86,17 @@ public class StateRegistry( * * @return [QueryChannelsState] object. */ - public fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsState { - return queryChannels.getOrPut(filter to sort) { - QueryChannelsMutableState(filter, sort, scope, latestUsers, activeLiveLocations) + public fun queryChannels(filter: FilterObject, sort: QuerySorter): QueryChannelsState = + queryChannels(QueryChannelsIdentifier.Standard(filter, sort)) + + /** + * Returns [QueryChannelsState] associated with the given [identifier]. + * Creates a fresh state if no entry exists for the identifier yet. + */ + @InternalStreamChatApi + public fun queryChannels(identifier: QueryChannelsIdentifier): QueryChannelsState { + return queryChannels.getOrPut(identifier) { + QueryChannelsMutableState(identifier, scope, latestUsers, activeLiveLocations) } } @@ -183,6 +197,32 @@ public class StateRegistry( internal fun getActiveChannelStates(): List = channels.values.toList() + /** + * Tracks a channel that was watched via [io.getstream.chat.android.state.extensions.watchChannelAsState]. + * The record lives as long as the caller holds the returned [StateFlow]. + * When the caller is GC'd, the record is GC'd and the weak reference goes null. + * Used during reconnect to re-watch only channels the user still has open. + * + * @param record The [WatchedChannelRecord] identifying the watched channel. + */ + internal fun trackWatchedChannel(record: WatchedChannelRecord) { + synchronized(watchedChannelRecords) { + watchedChannelRecords.removeAll { it.get() == null } + watchedChannelRecords.add(WeakReference(record)) + } + } + + /** + * Retrieves that channel CIDs which were registered via [trackWatchedChannel] and are still strongly referenced. + * Use to retrieve watched channels whose [StateFlow] is referenced by a consumer. + */ + internal fun getTrackedWatchedChannels(): Set { + synchronized(watchedChannelRecords) { + watchedChannelRecords.removeAll { it.get() == null } + return watchedChannelRecords.mapNotNull { it.get()?.cid }.toSet() + } + } + /** * Clear state of all state objects. */ @@ -196,6 +236,9 @@ public class StateRegistry( queryThreads.clear() threads.forEach { it.value.destroy() } threads.clear() + synchronized(watchedChannelRecords) { + watchedChannelRecords.clear() + } } internal fun handleBatchEvent(batchEvent: BatchEvent) { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt index 02f87fa28a6..ed3c49800d1 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/GlobalState.kt @@ -41,6 +41,14 @@ public interface GlobalState { */ public val channelUnreadCount: StateFlow + /** + * Per-group unread channel counts for the current user. + * The map keys are group identifiers provided by the backend (e.g. "direct", "support") + * and values are unread channel counts. + * Empty map when no grouped counts are available. + */ + public val groupedUnreadChannels: StateFlow> + /** * The number of unread threads for the current user. */ diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt index 7bdbaae39c2..dc0a89ef192 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/global/internal/MutableGlobalState.kt @@ -40,6 +40,7 @@ internal class MutableGlobalState( private var _totalUnreadCount: MutableStateFlow? = MutableStateFlow(0) private var _channelUnreadCount: MutableStateFlow? = MutableStateFlow(0) + private var _groupedUnreadChannels: MutableStateFlow>? = MutableStateFlow(emptyMap()) private var _unreadThreadsCount: MutableStateFlow? = MutableStateFlow(0) private var _banned: MutableStateFlow? = MutableStateFlow(false) private var _mutedUsers: MutableStateFlow>? = MutableStateFlow(emptyList()) @@ -52,6 +53,7 @@ internal class MutableGlobalState( override val totalUnreadCount: StateFlow = _totalUnreadCount!! override val channelUnreadCount: StateFlow = _channelUnreadCount!! + override val groupedUnreadChannels: StateFlow> = _groupedUnreadChannels!! override val unreadThreadsCount: StateFlow = _unreadThreadsCount!! override val muted: StateFlow> = _mutedUsers!! override val channelMutes: StateFlow> = _channelMutes!! @@ -71,6 +73,7 @@ internal class MutableGlobalState( fun destroy() { _totalUnreadCount = null _channelUnreadCount = null + _groupedUnreadChannels = null _unreadThreadsCount = null _mutedUsers = null _channelMutes = null @@ -90,6 +93,10 @@ internal class MutableGlobalState( _channelUnreadCount?.value = channelUnreadCount } + fun setGroupedUnreadChannels(groupedUnreadChannels: Map) { + _groupedUnreadChannels?.value = groupedUnreadChannels + } + fun setUnreadThreadsCount(unreadThreadsCount: Int) { _unreadThreadsCount?.value = unreadThreadsCount } diff --git a/stream-chat-android-state/src/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..7abd95c52f7 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCalls.kt @@ -22,8 +22,10 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.internal.logic import io.getstream.chat.android.state.extensions.state import io.getstream.chat.android.state.model.querychannels.pagination.internal.QueryChannelPaginationRequest import io.getstream.chat.android.state.plugin.state.StateRegistry @@ -70,6 +72,25 @@ internal class ChatClientStateCalls( .also { queryChannelsState -> queryChannelsState.chatEventHandlerFactory = chatEventHandlerFactory } } + /** + * Creates or retrieves the [QueryChannelsState] for the given grouped [identifier] without + * launching a remote queryChannels API call. Channels cached under the identifier's DB key + * are optimistically loaded into the state. + */ + internal suspend fun initGroupedQueryChannelsState( + identifier: QueryChannelsIdentifier.Grouped, + chatEventHandlerFactory: ChatEventHandlerFactory, + ): QueryChannelsState { + logger.d { "[initGroupedQueryChannelsState] identifier: $identifier" } + chatClient.clientState.user.first { it != null } + val state = deferredState + .await() + .queryChannels(identifier) + .apply { this.chatEventHandlerFactory = chatEventHandlerFactory } + chatClient.logic.queryChannels(identifier).loadOfflineGroupedChannels() + return state + } + /** Reference request of the channel query. */ private suspend fun queryChannel( channelType: String, diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelRecord.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelRecord.kt new file mode 100644 index 00000000000..750612b565c --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelRecord.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.plugin.state.internal + +/** + * Marker object that identifies an active channel watch. Held as a field inside + * [WatchedChannelStateFlow] so it lives as long as the caller holds the flow. + * [io.getstream.chat.android.state.plugin.state.StateRegistry] stores + * [java.lang.ref.WeakReference]s to these — when the flow is GC'd, the tracker + * is GC'd and the weak reference goes null. + */ +internal data class WatchedChannelRecord(val cid: String) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt new file mode 100644 index 00000000000..be55136b3c2 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.plugin.state.internal + +import io.getstream.chat.android.client.channel.state.ChannelState +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.flow.StateFlow + +/** + * A [StateFlow] wrapper that keeps a [WatchedChannelRecord] alive via a strong reference. + * Returned by [io.getstream.chat.android.state.extensions.watchChannelAsState] so the + * record's lifetime matches the caller's lifetime. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class WatchedChannelStateFlow( + private val delegate: StateFlow, + @Suppress("unused") private val record: WatchedChannelRecord, +) : StateFlow by delegate diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt index 932e3f8460e..112b00cd3c5 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState.kt @@ -31,10 +31,23 @@ public interface QueryChannelsState { /** If the channels need to be synced. */ public val recoveryNeeded: StateFlow - /** The filter is associated with this query channels state. */ + /** + * The filter associated with this query channels state. + * + * For grouped queries (states keyed by `QueryChannelsIdentifier.Grouped`), this is a + * placeholder ([io.getstream.chat.android.models.Filters.neutral]) — grouped lists are + * partitioned server-side and do not have a client-side filter. Do not rely on this value + * for grouped states. + */ public val filter: FilterObject - /** The sort object which requested for this query channels state. */ + /** + * The sort associated with this query channels state. + * + * For grouped queries (states keyed by `QueryChannelsIdentifier.Grouped`), this is a + * placeholder (`last_message_at` descending) — grouped lists are ordered server-side and + * do not have a client-side sort. Do not rely on this value for grouped states. + */ public val sort: QuerySorter /** The request for the current page. */ @@ -52,6 +65,12 @@ public interface QueryChannelsState { /** If the current state reached the final page. */ public val endOfChannels: StateFlow + /** + * Cursor for the next page when this state belongs to a grouped query. + * `null` means there is no further page (or that this is a standard query that doesn't use cursors). + */ + public val nextCursor: StateFlow + /** * The collection of channels loaded by the query channels request. * The StateFlow is initialized with null which means that channels are not loaded yet. diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt index 64a33bc7a13..98ac51bb0e1 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableState.kt @@ -20,18 +20,20 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.extensions.internal.updateLiveLocations import io.getstream.chat.android.client.extensions.internal.updateUsers +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState -import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -41,14 +43,21 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn internal class QueryChannelsMutableState( - override val filter: FilterObject, - override val sort: QuerySorter, + internal val identifier: QueryChannelsIdentifier, scope: CoroutineScope, latestUsers: StateFlow>, activeLiveLocations: StateFlow>, ) : QueryChannelsState { - private val logger by taggedLogger("Chat:QueryChannelsState") + override val filter: FilterObject = when (identifier) { + is QueryChannelsIdentifier.Standard -> identifier.filter + is QueryChannelsIdentifier.Grouped -> Filters.neutral() + } + + override val sort: QuerySorter = when (identifier) { + is QueryChannelsIdentifier.Standard -> identifier.sort + is QueryChannelsIdentifier.Grouped -> QuerySortByField.descByName("last_message_at") + } internal var rawChannels: Map? get() = _channels?.value @@ -56,8 +65,33 @@ internal class QueryChannelsMutableState( _channels?.value = value } - // This is needed for queries - internal val queryChannelsSpec: QueryChannelsSpec = QueryChannelsSpec(filter, sort) + // Mutable backing field — replaced (immutably) by setCids(). + private var _querySpec: QueryChannelsSpec = QueryChannelsSpec( + filter = filter, + querySort = sort, + cids = emptySet(), + groupKey = (identifier as? QueryChannelsIdentifier.Grouped)?.group, + ) + + /** + * Snapshot of the spec backing this state. Returned reference is immutable; use [setCids] to + * update. + */ + internal val queryChannelsSpec: QueryChannelsSpec + get() = _querySpec + + /** + * Replaces the held spec with a copy whose [QueryChannelsSpec.cids] is [cids]. + * [QueryChannelsSpec.filter], [QueryChannelsSpec.querySort], and [QueryChannelsSpec.groupKey] are preserved. + */ + internal fun setCids(cids: Set) { + _querySpec = QueryChannelsSpec( + filter = _querySpec.filter, + querySort = _querySpec.querySort, + cids = cids, + groupKey = _querySpec.groupKey, + ) + } /** * Property that exposes a map of raw channels. @@ -90,17 +124,24 @@ internal class QueryChannelsMutableState( private var _channelsOffset: MutableStateFlow? = MutableStateFlow(0) internal val channelsOffset: StateFlow = _channelsOffset!! + private var _nextCursor: MutableStateFlow? = MutableStateFlow(null) + override var chatEventHandlerFactory: ChatEventHandlerFactory? = null + set(value) { + field = value + _eventHandler = value?.chatEventHandler(mapChannels) + } override val recoveryNeeded: StateFlow = _recoveryNeeded!! /** * Non-nullable property of [ChatEventHandler] to ensure we always have some handler to handle events. Returns * handler set by user or default one if there is no. + * Re-created when [chatEventHandlerFactory] changes. */ - private val eventHandler: ChatEventHandler by lazy { - (chatEventHandlerFactory ?: ChatEventHandlerFactory()).chatEventHandler(mapChannels) - } + private var _eventHandler: ChatEventHandler? = null + private val eventHandler: ChatEventHandler + get() = _eventHandler ?: ChatEventHandlerFactory().chatEventHandler(mapChannels) fun handleChatEvent(event: ChatEvent, cachedChannel: Channel?): EventHandlingResult { return eventHandler.handleChatEvent(event, filter, cachedChannel) @@ -110,6 +151,7 @@ internal class QueryChannelsMutableState( override val loading: StateFlow = _loading!! override val loadingMore: StateFlow = _loadingMore!! override val endOfChannels: StateFlow = _endOfChannels!! + override val nextCursor: StateFlow = _nextCursor!! override val channels: StateFlow?> = sortedChannels override val channelsStateData: StateFlow = loading.combine(sortedChannels) { loading: Boolean, channels: List? -> @@ -151,7 +193,7 @@ internal class QueryChannelsMutableState( /** * Set the end of channels. * - * @parami isEnd Boolean + * @param isEnd Boolean */ fun setEndOfChannels(isEnd: Boolean) { _endOfChannels?.value = isEnd @@ -179,6 +221,14 @@ internal class QueryChannelsMutableState( rawChannels = channelsMap } + /** + * Set the next-page cursor. Used by the grouped-channels path; the standard path doesn't + * publish a cursor here. + */ + fun setNextCursor(cursor: String?) { + _nextCursor?.value = cursor + } + fun destroy() { _channels = null _loading = null @@ -187,6 +237,7 @@ internal class QueryChannelsMutableState( _currentRequest = null _recoveryNeeded = null _channelsOffset = null + _nextCursor = null } } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index d89eff4c337..1f750e9497c 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -84,7 +84,7 @@ private const val SYNC_MAX_CIDS = 100 * This class is responsible to sync messages, reactions and channel data. It tries to sync then, if necessary, * when connection is reestablished or when a health check event happens. */ -@Suppress("LongParameterList", "TooManyFunctions", "TooGenericExceptionCaught") +@Suppress("LongParameterList", "TooManyFunctions", "TooGenericExceptionCaught", "LargeClass") internal class SyncManager( private val currentUserId: String, private val chatClient: ChatClient, @@ -407,28 +407,89 @@ internal class SyncManager( private suspend fun restoreActiveChannels() { val recoverAll = !isFirstConnect.compareAndSet(true, false) logger.d { "[restoreActiveChannels] recoverAll: $recoverAll" } - when (val result = updateActiveQueryChannels(recoverAll)) { - is Result.Success -> { - val updatedCids = result.value - logger.v { "[restoreActiveChannels] updatedCids.size: ${updatedCids.size}" } - updateActiveChannels( - recoverAll, - updatedCids, - ) - } - is Result.Failure -> { - logger.e { "[restoreActiveChannels] failed: ${result.value}" } - return + val allLogics = logicRegistry.getActiveQueryChannelsLogic() + val hasGroupedQueries = allLogics.any { it.groupKey() != null } + val hasStandardQueries = allLogics.any { it.groupKey() == null } + + // --- GroupedQueryChannels path --- + if (hasGroupedQueries) { + // Refresh first page of the queries populated via GroupedQueryChannels (doesn't re-watch) + updateGroupedQueryChannels(recoverAll) + // Re-watch tracked channels (specific for this path, where we don't re-watch the groups, just the manually + // opened/tracked channels) + rewatchTrackedWatchedChannels() + } + + // --- QueryChannels path --- + if (hasStandardQueries) { + when (val result = updateActiveQueryChannels(recoverAll)) { + is Result.Success -> { + val updatedCids = result.value + logger.v { "[restoreActiveChannels] standardCids.size: ${result.value.size}" } + updateActiveChannels(recoverAll, updatedCids) + } + is Result.Failure -> { + logger.e { "[restoreActiveChannels] standard query failed: ${result.value}" } + return + } } } } + /** + * Drives the recovery flow for grouped channel queries: when at least one active grouped + * logic needs recovery, calls [ChatClient.queryGroupedChannels] once. The + * [io.getstream.chat.android.state.plugin.listener.internal.QueryGroupedChannelsListenerState] + * routes the response into the corresponding per-group state and persists it. + */ + private suspend fun updateGroupedQueryChannels(recoverAll: Boolean) { + val hasGroupedRecovery = logicRegistry.getActiveQueryChannelsLogic() + .any { it.groupKey() != null && (it.recoveryNeeded().value || recoverAll) } + + if (!hasGroupedRecovery) { + logger.v { "[updateGroupedQueryChannels] no grouped queries to restore" } + return + } + + // TODO: Align with iOS before publishing! + val result = chatClient.queryGroupedChannels( + limit = null, + groups = null, + watch = true, + presence = true, + ).await() + + when (result) { + is Result.Success -> + logger.v { "[updateGroupedQueryChannels] succeeded (listener applied)" } + is Result.Failure -> + logger.e { "[updateGroupedQueryChannels] queryGroupedChannels failed: ${result.value}" } + } + } + + /** + * Re-watches channels explicitly opened by the user (tracked via + * [io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord] weak references in [StateRegistry]). + */ + private suspend fun rewatchTrackedWatchedChannels() { + val online = clientState.isOnline + val watchedCids = stateRegistry.getTrackedWatchedChannels() + logger.d { "[rewatchTrackedWatchedChannels] watchedCids.size: ${watchedCids.size}, online: $online" } + if (watchedCids.isEmpty() || !online) return + + watchedCids.forEach { cid -> + val (type, id) = cid.cidToTypeAndId() + logicRegistry.channel(type, id).watch(userPresence = userPresence) + } + } + private suspend fun updateActiveQueryChannels(recoverAll: Boolean): Result> { // 2. update the results for queries that are actively being shown right now (synchronous) logger.d { "[updateActiveQueryChannels] recoverAll: $recoverAll" } val queryLogicsToRestore = logicRegistry.getActiveQueryChannelsLogic() .asSequence() + .filter { it.groupKey() == null } .filter { queryChannelsLogic -> queryChannelsLogic.recoveryNeeded().value || recoverAll } .take(QUERIES_TO_RETRY) .toList() diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt new file mode 100644 index 00000000000..93b3927dee4 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/DefaultChannelGroupResolverTest.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.event.handler.chat + +import io.getstream.chat.android.randomString +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class DefaultChannelGroupResolverTest { + + @Test + fun `Given a channel_custom with an explicit group When resolved Then returns the group plus the all sentinel`() { + val channelCustom: Map = mapOf("group" to "vip") + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channelCustom, currentGroup = randomString()) + + assertEquals(setOf("vip", "all"), result) + } + + @Test + fun `Given a null channel_custom When resolved Then returns only the all sentinel`() { + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channelCustom = null, currentGroup = randomString()) + + assertEquals(setOf("all"), result) + } + + @Test + fun `Given an empty channel_custom When resolved Then returns only the all sentinel`() { + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channelCustom = emptyMap(), currentGroup = randomString()) + + assertEquals(setOf("all"), result) + } + + @Test + fun `Given a custom group field name When resolved Then reads that field`() { + val channelCustom: Map = mapOf("tier" to "gold", "group" to "ignored") + val resolver = DefaultChannelGroupResolver(groupFieldName = "tier") + + val result = resolver.resolve(channelCustom, currentGroup = randomString()) + + assertEquals(setOf("gold", "all"), result) + } + + @Test + fun `Given the all sentinel is disabled When resolved Then returns only the explicit group`() { + val channelCustom: Map = mapOf("group" to "vip") + val resolver = DefaultChannelGroupResolver(allGroupKey = null) + + val result = resolver.resolve(channelCustom, currentGroup = randomString()) + + assertEquals(setOf("vip"), result) + } + + @Test + fun `Given a non-string group value When resolved Then ignores it and returns only the all sentinel`() { + val channelCustom: Map = mapOf("group" to 42) + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channelCustom, currentGroup = randomString()) + + assertEquals(setOf("all"), result) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt new file mode 100644 index 00000000000..431e38314ba --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/chat/GroupAwareChatEventHandlerTest.kt @@ -0,0 +1,343 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.event.handler.chat + +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.client.test.randomChannelDeletedEvent +import io.getstream.chat.android.client.test.randomChannelUpdatedEvent +import io.getstream.chat.android.client.test.randomMemberAddedEvent +import io.getstream.chat.android.client.test.randomMemberRemovedEvent +import io.getstream.chat.android.client.test.randomNewMessageEvent +import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent +import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomUser +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +internal class GroupAwareChatEventHandlerTest { + + private val defaultResolver = DefaultChannelGroupResolver() + + @Test + fun `Given channel belongs to this group and is not cached When ChannelUpdatedEvent arrives Should add`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given channel belongs to this group and is already cached When ChannelUpdatedEvent arrives Should skip`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given channel moved to another group and is currently cached When ChannelUpdatedEvent arrives Should remove`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "other"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given channel belongs to another group and is not cached When ChannelUpdatedEvent arrives Should skip`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "other"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given handler is for the all group When ChannelUpdatedEvent arrives Should always add`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap()) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given channel does not belong here When NotificationAddedToChannelEvent arrives Should skip`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationAddedToChannelEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "other"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given channel belongs here When NotificationAddedToChannelEvent arrives Should watch and add`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationAddedToChannelEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given channel does not belong here When NotificationMessageNewEvent arrives Should skip`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationMessageNewEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "other"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given NewMessageEvent for another group Should skip without consulting super`() { + val channel = randomChannel() + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + ) + val event = randomNewMessageEvent( + cid = channel.cid, + channelCustom = mapOf("group" to "other"), + ) + + // cachedChannel is provided; if super were consulted it could return Add. We assert Skip. + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given NewMessageEvent for this group with cached channel absent from list Should add`() { + val channel = randomChannel() + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + ) + val event = randomNewMessageEvent( + cid = channel.cid, + message = randomMessage(type = "regular"), + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given NewMessageEvent for this group but no cached channel Should skip`() { + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNewMessageEvent( + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given system NewMessageEvent for this group Should skip via super`() { + val channel = randomChannel() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNewMessageEvent( + cid = channel.cid, + message = randomMessage(type = "system"), + channelCustom = mapOf("group" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given current user joined with a cached channel When MemberAddedEvent arrives Should add via super`() { + // Member events do not carry channel_custom and the handler trusts super (no group filter). + val currentUser = randomUser() + val channel = randomChannel() + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomMemberAddedEvent( + cid = channel.cid, + member = randomMember(user = currentUser), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given current user left a cached channel When MemberRemovedEvent arrives Should remove regardless of group`() { + val currentUser = randomUser() + val channel = randomChannel() + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = randomMemberRemovedEvent( + cid = channel.cid, + member = randomMember(user = currentUser), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a cached channel When ChannelDeletedEvent arrives Should remove regardless of group`() { + val channel = randomChannel() + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + ) + val event = randomChannelDeletedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a custom resolver that reads a different field When ChannelUpdatedEvent arrives Should use custom field`() { + val channel = randomChannel() + val customResolver = ChannelGroupResolver { channelCustom, _ -> + setOfNotNull(channelCustom?.get("tier") as? String) + } + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + resolver = customResolver, + ) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("tier" to "vip"), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given custom resolver Should receive the handler's groupKey as currentGroup`() { + val channel = randomChannel() + var capturedGroup: String? = null + val capturingResolver = ChannelGroupResolver { _, currentGroup -> + capturedGroup = currentGroup + setOf("vip") + } + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + resolver = capturingResolver, + ) + val event = randomChannelUpdatedEvent( + cid = channel.cid, + channel = channel, + channelCustom = mapOf("group" to "vip"), + ) + + handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals("vip", capturedGroup) + } + + private fun handlerFor( + groupKey: String, + cachedChannels: Map, + resolver: ChannelGroupResolver = defaultResolver, + currentUser: User? = null, + ): GroupAwareChatEventHandler { + val clientState = mock { + whenever(it.user) doReturn MutableStateFlow(currentUser) + } + return GroupAwareChatEventHandler( + groupKey = groupKey, + resolver = resolver, + channels = MutableStateFlow(cachedChannels), + clientState = clientState, + ) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt index 3d3fac45a5a..d280277fd0c 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -151,6 +151,27 @@ internal class EventHandlerSequentialTest { verify(repos).deletePoll(event.poll.id) } + @ParameterizedTest + @MethodSource("groupedUnreadChannelsArguments") + internal fun `GlobalState should be updated with proper groupedUnreadChannels values`( + events: List, + initialGroupedUnreadChannels: Map, + prepareFixture: Fixture.() -> Unit, + expectedGroupedUnreadChannels: Map, + ) = runTest { + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(initialGroupedUnreadChannels) + } + val handler = Fixture() + .withMutableGlobalState(mutableGlobalState) + .apply(prepareFixture) + .get(this) + + handler.handleEvents(*events.toTypedArray()) + + mutableGlobalState.groupedUnreadChannels.value `should be equal to` expectedGroupedUnreadChannels + } + @ParameterizedTest @MethodSource("sharedLocationArguments") fun `GlobalState should be updated with shared locations`( @@ -234,6 +255,96 @@ internal class EventHandlerSequentialTest { withCurrentUser(currentUser) } + private val groupedUnreadChannels = mapOf("direct" to positiveRandomInt(), "support" to positiveRandomInt()) + private val initialGroupedUnreadChannels = mapOf("old" to positiveRandomInt()) + + @Suppress("LongMethod") + @JvmStatic + fun groupedUnreadChannelsArguments() = listOf( + // NewMessageEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNewMessageEvent( + cid = randomCid, + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + prepareFixtureWithReadCapability, + groupedUnreadChannels, + ), + // NotificationMarkReadEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationMarkReadEvent( + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // NotificationMarkUnreadEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationMarkUnreadEvent( + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // NotificationMessageNewEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationMessageNewEvent( + cid = randomCid, + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // NotificationChannelDeletedEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationChannelDeletedEvent( + cid = randomCid, + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + neutralPrepareFixture, + groupedUnreadChannels, + ), + // NotificationChannelTruncatedEvent with grouped unreads updates GlobalState + Arguments.of( + listOf( + randomNotificationChannelTruncatedEvent( + cid = randomCid, + groupedUnreadChannels = groupedUnreadChannels, + ), + ), + initialGroupedUnreadChannels, + prepareFixtureWithReadCapability, + groupedUnreadChannels, + ), + // Event with null grouped unreads preserves previous value + Arguments.of( + listOf( + randomNewMessageEvent( + cid = randomCid, + groupedUnreadChannels = null, + ), + ), + initialGroupedUnreadChannels, + prepareFixtureWithReadCapability, + initialGroupedUnreadChannels, + ), + ) + @JvmStatic fun unreadCountArguments() = unreadArgumentMarkAllReadEvent() + unreadArgumentNewMessageEvent() + diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt index 52a1cc03e3a..e23aefb3543 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt @@ -31,6 +31,8 @@ import io.getstream.chat.android.client.utils.internal.ServerClockOffset import io.getstream.chat.android.client.utils.observable.Disposable import io.getstream.chat.android.core.internal.coroutines.Tube import io.getstream.chat.android.models.ConnectionState +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.TimeDuration @@ -45,6 +47,7 @@ import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.state.sync.internal.SyncManager @@ -540,6 +543,166 @@ internal class SyncManagerTest { verify(chatClient, never()).getSyncHistory(any(), any()) } + // region Grouped Channels Sync + + @Test + fun `on reconnect should call queryGroupedChannels instead of queryFirstPage for prefilled queries`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val channelA = randomChannel(type = "messaging", id = "a") + val channelB = randomChannel(type = "messaging", id = "b") + + val queryLogic: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( + Result.Success( + GroupedChannels( + groups = mapOf("all" to GroupedChannelsGroup(groupKey = "all", channels = listOf(channelA, channelB))), + ), + ), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + + // First event marks isFirstConnect=false + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + // Second event triggers reconnect with recoverAll=true + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + verify(chatClient).queryGroupedChannels() + verify(queryLogic, never()).queryFirstPage() + } + + @Test + fun `on reconnect with only grouped queries updateActiveChannels should not run`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val channelA = randomChannel(type = "messaging", id = "a") + + val queryLogic: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( + Result.Success( + GroupedChannels( + groups = mapOf("all" to GroupedChannelsGroup(groupKey = "all", channels = listOf(channelA))), + ), + ), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + // The standard path (updateActiveChannels) should not run when only grouped queries exist. + verify(chatClient, never()).queryChannelsInternal(any()) + } + + @Test + fun `on reconnect with grouped query failure standard path should not run`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val queryLogic: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever(chatClient.queryGroupedChannels(watch = true)) doReturn TestCall( + Result.Failure( + Error.NetworkError(message = "fail", serverErrorCode = 0, statusCode = 500), + ), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + // The standard path should not run when only grouped queries exist, even on failure. + verify(chatClient, never()).queryChannelsInternal(any()) + } + + @Test + fun `updateActiveQueryChannels should skip grouped queries`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val groupedQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(true) + } + + val standardQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn null + on(it.recoveryNeeded()) doReturn MutableStateFlow(true) + onBlocking { it.queryFirstPage() } doReturn Result.Success(emptyList()) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(groupedQuery, standardQuery) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(stateRegistry.getActiveChannelStates()) doReturn emptyList() + whenever(chatClient.queryGroupedChannels()) doReturn TestCall( + Result.Success(GroupedChannels(groups = emptyMap())), + ) + whenever(clientState.isOnline) doReturn true + whenever(repositoryFacade.selectSyncState(user.id)) doReturn null + + val syncManager = buildSyncManager() + + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + syncManager.onEvent(connectedEvent(createdAt, rawCreatedAt)) + delay(100) + + // Standard query should have queryFirstPage called (once per connect event) + verify(standardQuery, times(2)).queryFirstPage() + // Grouped query should NOT have queryFirstPage called + verify(groupedQuery, never()).queryFirstPage() + } + + private fun connectedEvent(createdAt: Date, rawCreatedAt: String) = ConnectedEvent( + type = "type", + createdAt = createdAt, + rawCreatedAt = rawCreatedAt, + connectionId = randomString(), + me = user, + ) + + // endregion + private fun TestScope.localRandomMessage() = randomMessage( createdLocallyAt = Date(currentTime), createdAt = null, diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt new file mode 100644 index 00000000000..00e54ee7efb --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt @@ -0,0 +1,207 @@ +/* + * 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.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.models.GroupedChannelsGroupQuery +import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic +import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.result.Error +import io.getstream.result.Result +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class QueryGroupedChannelsListenerStateTest { + + private val queryChannelsLogic: QueryChannelsLogic = mock() + private val logic: LogicRegistry = mock { + on(it.queryChannels(any())) doReturn queryChannelsLogic + } + private val globalState: MutableGlobalState = mock() + private val listener = QueryGroupedChannelsListenerState(logic, globalState) + + @Test + fun `successful result merges returned unread counts into existing global state`() = runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow( + mapOf("direct" to 4, "support" to 1), + ) + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf( + "support" to GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 7, + next = null, + prev = null, + ), + ), + ), + ) + // when + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery(next = "cursor")), + watch = false, + presence = false, + ) + // then - merged: direct stays at 4, support updated to 7 + verify(globalState, times(1)).setGroupedUnreadChannels( + mapOf("direct" to 4, "support" to 7), + ) + } + + @Test + fun `successful result with groups null merges into existing state`() = runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow( + mapOf("direct" to 4), + ) + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf( + "direct" to GroupedChannelsGroup( + groupKey = "direct", + channels = emptyList(), + unreadChannels = 2, + next = null, + prev = null, + ), + "support" to GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 0, + next = null, + prev = null, + ), + ), + ), + ) + // when - groups param is null (default set requested) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = null, + watch = false, + presence = false, + ) + // then - direct updated to 2, support added with 0; merging preserves any pre-existing keys + verify(globalState, times(1)).setGroupedUnreadChannels( + mapOf("direct" to 2, "support" to 0), + ) + } + + @Test + fun `failure result does not touch global state`() = runTest { + // given + val result = Result.Failure(Error.GenericError("network")) + // when + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = null, + watch = false, + presence = false, + ) + // then + verify(globalState, never()).setGroupedUnreadChannels(any()) + } + + @Test + fun `success routes each returned group to the matching Grouped identifier as first page when no next cursor was requested`() = + runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow(emptyMap()) + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val groupDirect = GroupedChannelsGroup( + groupKey = "direct", + channels = emptyList(), + unreadChannels = 0, + next = null, + prev = null, + ) + val groupSupport = GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 0, + next = "cursor-support", + prev = null, + ) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf("direct" to groupDirect, "support" to groupSupport), + ), + ) + // when - groups param is null (default set requested → both treated as first page) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = null, + watch = false, + presence = false, + ) + // then + verify(logic).queryChannels(eq(QueryChannelsIdentifier.Grouped("direct"))) + verify(logic).queryChannels(eq(QueryChannelsIdentifier.Grouped("support"))) + verify(queryChannelsLogic).applyGroupedResult(groupDirect, isFirstPage = true) + verify(queryChannelsLogic).applyGroupedResult(groupSupport, isFirstPage = true) + } + + @Test + fun `success treats keys with requested next cursor as paginated`() = runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow(emptyMap()) + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val groupSupport = GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 0, + next = null, + prev = null, + ) + val result = Result.Success( + value = GroupedChannels(groups = mapOf("support" to groupSupport)), + ) + // when - the request passed a next cursor for "support" + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery(next = "cursor")), + watch = false, + presence = false, + ) + // then + verify(queryChannelsLogic).applyGroupedResult(groupSupport, isFirstPage = false) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 089523a6e7a..b341f9d61a2 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -18,12 +18,14 @@ 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 import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.GroupedChannelsGroup import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult @@ -74,8 +76,7 @@ internal class QueryChannelsLogicTest { whenever(queryChannelsStateLogic.getQuerySpecs()) doReturn queryChannelsSpec logic = QueryChannelsLogic( - filter = filter, - sort = sort, + identifier = QueryChannelsIdentifier.Standard(filter, sort), client = client, queryChannelsStateLogic = queryChannelsStateLogic, queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, @@ -357,4 +358,80 @@ internal class QueryChannelsLogicTest { } // endregion + + // region loadOfflineChannels + + @Test + fun `loadOfflineChannels populates state from cache`() = runTest { + // Given + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + val cachedChannels = listOf(randomChannel(), randomChannel()) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn cachedChannels + + // When + logic.loadOfflineChannels(request) + + // Then + verify(queryChannelsStateLogic).setCurrentRequest(request) + verify(queryChannelsStateLogic).addChannelsState(cachedChannels) + verify(queryChannelsStateLogic).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + verify(queryChannelsStateLogic, never()).setChannelsOffset(any()) + } + + @Test + fun `loadOfflineChannels handles null cache gracefully`() = runTest { + // Given + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn null + + // When + logic.loadOfflineChannels(request) + + // Then + verify(queryChannelsStateLogic).setCurrentRequest(request) + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + } + + @Test + fun `loadOfflineChannels skips when channels already populated`() = runTest { + // Given - race condition: channels were populated by a concurrent prefill + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + val existingChannels = mapOf("messaging:ch1" to randomChannel()) + whenever(queryChannelsStateLogic.getChannels()) doReturn existingChannels + whenever(queryChannelsDatabaseLogic.fetchChannelsFromCache(any(), any())) doReturn listOf(randomChannel()) + + // When + logic.loadOfflineChannels(request) + + // Then - only setCurrentRequest should be called, nothing else + verify(queryChannelsStateLogic).setCurrentRequest(request) + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic, never()).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic, never()).setLoadingFirstPage(any()) + } + + // endregion + + // region applyGroupedResult + + @Test + fun `applyGroupedResult is a no-op on non-Grouped identifiers`() = runTest { + // Given — logic is constructed with a Standard identifier in setUp. + val channels = listOf(randomChannel(id = "new1")) + val group = GroupedChannelsGroup(groupKey = "key", channels = channels) + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then — no state mutations on a non-Grouped logic. + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic, never()).setNextCursor(any()) + } + + // endregion } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt index 4845c0ec1d7..235ebb52bd7 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt @@ -16,26 +16,33 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal +import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.client.extensions.internal.toCid import io.getstream.chat.android.client.query.QueryChannelsSpec +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import org.amshove.kluent.`should contain same` import org.junit.Rule import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -52,10 +59,7 @@ internal class QueryChannelsStateLogicTest { private val testCid = (type to id).toCid() private val queryChannelsSpec = - QueryChannelsSpec(Filters.neutral(), QuerySortByField.descByName("")) - .apply { - cids = setOf(testCid) - } + QueryChannelsSpec(Filters.neutral(), QuerySortByField.descByName(""), setOf(testCid)) private val mutableState: QueryChannelsMutableState = mock { on(it.rawChannels) doReturn emptyMap() @@ -132,7 +136,7 @@ internal class QueryChannelsStateLogicTest { queryChannelsStateLogic.addChannelsState(channels) - queryChannelsSpec.cids `should contain same` setOf(testCid, channel1.cid, channel2.cid) + verify(mutableState).setCids(setOf(testCid, channel1.cid, channel2.cid)) verify(mutableState).setChannels(channels.associateBy { it.cid }) } @@ -159,4 +163,182 @@ internal class QueryChannelsStateLogicTest { assertNull(result) } + + // region Delegation + + @Test + fun `setLoadingMore delegates to mutableState`() { + queryChannelsStateLogic.setLoadingMore(true) + verify(mutableState).setLoadingMore(true) + } + + @Test + fun `setLoadingFirstPage delegates to mutableState`() { + queryChannelsStateLogic.setLoadingFirstPage(true) + verify(mutableState).setLoadingFirstPage(true) + } + + @Test + fun `setCurrentRequest delegates to mutableState`() { + val request = QueryChannelsRequest(filter = Filters.neutral(), limit = 30) + queryChannelsStateLogic.setCurrentRequest(request) + verify(mutableState).setCurrentRequest(request) + } + + @Test + fun `setEndOfChannels delegates to mutableState`() { + queryChannelsStateLogic.setEndOfChannels(true) + verify(mutableState).setEndOfChannels(true) + } + + @Test + fun `setRecoveryNeeded delegates to mutableState`() { + queryChannelsStateLogic.setRecoveryNeeded(true) + verify(mutableState).setRecoveryNeeded(true) + } + + // endregion + + // region removeChannels + + @Test + fun `removeChannels removes cids from spec and channels from state`() { + val chA = randomChannel(type = "messaging", id = "a") + val chB = randomChannel(type = "messaging", id = "b") + val chC = randomChannel(type = "messaging", id = "c") + val channels = mapOf(chA.cid to chA, chB.cid to chB, chC.cid to chC) + val spec = QueryChannelsSpec( + filter = Filters.neutral(), + querySort = QuerySortByField.descByName(""), + cids = setOf(chA.cid, chB.cid, chC.cid), + ) + + whenever(mutableState.rawChannels) doReturn channels + whenever(mutableState.queryChannelsSpec) doReturn spec + + val logic = QueryChannelsStateLogic(mutableState, stateRegistry, logicRegistry, testCoroutines.scope) + logic.removeChannels(setOf(chA.cid, chC.cid)) + + verify(mutableState).setCids(setOf(chB.cid)) + verify(mutableState).setChannels(mapOf(chB.cid to chB)) + } + + @Test + fun `removeChannels is no-op when rawChannels is null`() { + whenever(mutableState.rawChannels) doReturn null + + queryChannelsStateLogic.removeChannels(setOf("messaging:x")) + + verify(mutableState, never()).setChannels(any()) + } + + // endregion + + // region initializeChannelsIfNeeded + + @Test + fun `initializeChannelsIfNeeded sets empty map when rawChannels is null`() { + whenever(mutableState.rawChannels) doReturn null + + queryChannelsStateLogic.initializeChannelsIfNeeded() + + verify(mutableState).setChannels(emptyMap()) + } + + @Test + fun `initializeChannelsIfNeeded does not overwrite when rawChannels is already set`() { + val existing = mapOf("messaging:ch" to randomChannel()) + whenever(mutableState.rawChannels) doReturn existing + + queryChannelsStateLogic.initializeChannelsIfNeeded() + + verify(mutableState, never()).setChannels(any()) + } + + // endregion + + // region incrementChannelsOffset + + @Test + fun `incrementChannelsOffset adds size to current offset`() { + whenever(mutableState.channelsOffset) doReturn MutableStateFlow(10) + + queryChannelsStateLogic.incrementChannelsOffset(5) + + verify(mutableState).setChannelsOffset(15) + } + + // endregion + + // region addChannelsState edge cases + + @Test + fun `addChannelsState merges messages from existing channels`() = runTest { + val msg1 = randomMessage(id = "m1") + val msg2 = randomMessage(id = "m2") + val channelType = "messaging" + val channelId = "ch1" + val cid = "$channelType:$channelId" + val existingChannel = randomChannel(type = channelType, id = channelId).copy(messages = listOf(msg1)) + val newChannel = existingChannel.copy(messages = listOf(msg2)) + whenever(mutableState.rawChannels) doReturn mapOf(cid to existingChannel) + + queryChannelsStateLogic.addChannelsState(listOf(newChannel)) + + val captor = argumentCaptor>() + verify(mutableState).setChannels(captor.capture()) + val merged = captor.firstValue[cid]!! + val messageIds = merged.messages.map { it.id }.toSet() + assertTrue(messageIds.contains("m1")) + assertTrue(messageIds.contains("m2")) + } + + @Test + fun `addChannelsState deduplicates messages by id`() = runTest { + val sharedMsg = randomMessage(id = "shared") + val channelType = "messaging" + val channelId = "ch1" + val cid = "$channelType:$channelId" + val existingChannel = randomChannel(type = channelType, id = channelId).copy(messages = listOf(sharedMsg)) + val newChannel = existingChannel.copy(messages = listOf(sharedMsg.copy(text = "updated"))) + whenever(mutableState.rawChannels) doReturn mapOf(cid to existingChannel) + + queryChannelsStateLogic.addChannelsState(listOf(newChannel)) + + val captor = argumentCaptor>() + verify(mutableState).setChannels(captor.capture()) + val merged = captor.firstValue[cid]!! + assertEquals(1, merged.messages.count { it.id == "shared" }) + } + + @Test + fun `addChannelsState merges members when total does not exceed memberCount`() = runTest { + val userA = randomUser(id = "userA") + val userB = randomUser(id = "userB") + val memberA = randomMember(user = userA) + val memberB = randomMember(user = userB) + val channelType = "messaging" + val channelId = "ch1" + val cid = "$channelType:$channelId" + val existingChannel = randomChannel(type = channelType, id = channelId).copy( + members = listOf(memberA), + memberCount = 10, + ) + val newChannel = existingChannel.copy( + members = listOf(memberB), + memberCount = 10, + ) + whenever(mutableState.rawChannels) doReturn mapOf(cid to existingChannel) + + queryChannelsStateLogic.addChannelsState(listOf(newChannel)) + + val captor = argumentCaptor>() + verify(mutableState).setChannels(captor.capture()) + val merged = captor.firstValue[cid]!! + val memberUserIds = merged.members.map { it.getUserId() }.toSet() + assertTrue(memberUserIds.contains("userA")) + assertTrue(memberUserIds.contains("userB")) + } + + // endregion } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt index 943a97b17a5..f7a6a124ea0 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/StateRegistryTest.kt @@ -24,13 +24,16 @@ import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySortByField.Companion.descByName import io.getstream.chat.android.state.plugin.config.MessageLimitConfig +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelRecord import io.getstream.chat.android.test.TestCoroutineExtension import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.TestScope +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNotSame import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -249,4 +252,41 @@ internal class StateRegistryTest { // Then assertSame(state1, state2) } + + // region WatchedChannelRecord tracking + + @Test + fun `trackWatchedChannel should make CID available in getTrackedWatchedChannels`() { + val record = WatchedChannelRecord("messaging:123") + stateRegistry.trackWatchedChannel(record) + + assertEquals(setOf("messaging:123"), stateRegistry.getTrackedWatchedChannels()) + } + + @Test + fun `getTrackedWatchedChannels should return empty set when nothing tracked`() { + assertTrue(stateRegistry.getTrackedWatchedChannels().isEmpty()) + } + + @Test + fun `trackWatchedChannel should deduplicate CIDs from multiple records`() { + val record1 = WatchedChannelRecord("messaging:123") + val record2 = WatchedChannelRecord("messaging:123") + stateRegistry.trackWatchedChannel(record1) + stateRegistry.trackWatchedChannel(record2) + + assertEquals(setOf("messaging:123"), stateRegistry.getTrackedWatchedChannels()) + } + + @Test + fun `clear should remove all tracked records`() { + val record = WatchedChannelRecord("messaging:123") + stateRegistry.trackWatchedChannel(record) + + stateRegistry.clear() + + assertTrue(stateRegistry.getTrackedWatchedChannels().isEmpty()) + } + + // endregion } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt new file mode 100644 index 00000000000..c3a3643038b --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/internal/ChatClientStateCallsTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.plugin.state.internal + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.models.User +import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.plugin.internal.StatePlugin +import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.logic.querychannels.internal.QueryChannelsLogic +import io.getstream.chat.android.state.plugin.state.StateRegistry +import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ChatClientStateCallsTest { + + @get:Rule + val testCoroutines = TestCoroutineRule() + + private lateinit var chatClient: ChatClient + private lateinit var clientState: ClientState + private lateinit var stateRegistry: StateRegistry + private lateinit var logicRegistry: LogicRegistry + private lateinit var queryChannelsLogic: QueryChannelsLogic + private lateinit var queryChannelsState: QueryChannelsState + private lateinit var chatClientStateCalls: ChatClientStateCalls + + private val userFlow = MutableStateFlow(null) + private val identifier = QueryChannelsIdentifier.Grouped("test-group") + + @BeforeEach + fun setUp() { + clientState = mock { + on(it.user) doReturn userFlow + } + queryChannelsState = mock() + stateRegistry = mock { + on(it.queryChannels(any(), any())) doReturn queryChannelsState + on(it.queryChannels(any())) doReturn queryChannelsState + } + queryChannelsLogic = mock() + logicRegistry = mock { + on(it.queryChannels(any())) doReturn queryChannelsLogic + } + + val statePlugin: StatePlugin = mock { + on(it.resolveDependency(eq(StateRegistry::class))) doReturn stateRegistry + on(it.resolveDependency(eq(LogicRegistry::class))) doReturn logicRegistry + } + + chatClient = mock { + on(it.plugins) doReturn listOf(statePlugin) + on(it.clientState) doReturn clientState + on(it.awaitInitializationState(any())) doReturn InitializationState.COMPLETE + } + + chatClientStateCalls = ChatClientStateCalls(chatClient, testCoroutines.scope) + } + + @Test + fun `initGroupedQueryChannelsState creates state without API call`() = runTest { + // Given - user is connected + userFlow.value = User(id = "test-user") + val factory = ChatEventHandlerFactory(clientState) + + // When + val result = chatClientStateCalls.initGroupedQueryChannelsState(identifier, factory) + + // Then — no remote queryChannels API call; the offline grouped load runs locally. + verify(chatClient, never()).queryChannels(any()) + assertNotNull(result) + } + + @Test + fun `initGroupedQueryChannelsState waits for user before proceeding`() = runTest { + // Given - user is NOT connected yet + val factory = ChatEventHandlerFactory(clientState) + var completed = false + + // When - launch initGroupedQueryChannelsState (it should suspend waiting for user) + val job = launch { + chatClientStateCalls.initGroupedQueryChannelsState(identifier, factory) + completed = true + } + advanceUntilIdle() + + // Then - should not have completed yet + assertEquals(false, completed) + + // When - user connects + userFlow.value = User(id = "test-user") + advanceUntilIdle() + + // Then - should complete now + assertEquals(true, completed) + job.cancel() + } + + @Test + fun `initGroupedQueryChannelsState returns state matching the identifier`() = runTest { + // Given + userFlow.value = User(id = "test-user") + val factory = ChatEventHandlerFactory(clientState) + + // When + chatClientStateCalls.initGroupedQueryChannelsState(identifier, factory) + + // Then - stateRegistry.queryChannels should be called with the identifier + verify(stateRegistry).queryChannels(identifier) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt new file mode 100644 index 00000000000..795f7ddfaf1 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt @@ -0,0 +1,257 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.plugin.state.querychannels.internal + +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.Location +import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData +import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.Date + +@OptIn(ExperimentalCoroutinesApi::class) +internal class QueryChannelsMutableStateTest { + + @get:Rule + val testCoroutines = TestCoroutineRule() + + private val filter = Filters.eq("type", "messaging") + private val sort = QuerySortByField.descByName("last_message_at") + private val identifier = io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier + .Standard(filter, sort) + private val latestUsers = MutableStateFlow>(emptyMap()) + private val activeLiveLocations = MutableStateFlow>(emptyList()) + + private lateinit var state: QueryChannelsMutableState + + @BeforeEach + fun setUp() { + state = QueryChannelsMutableState( + identifier = identifier, + scope = testCoroutines.scope, + latestUsers = latestUsers, + activeLiveLocations = activeLiveLocations, + ) + } + + // region Setters + + @Test + fun `setLoadingMore updates loadingMore flow`() = runTest { + assertFalse(state.loadingMore.value) + state.setLoadingMore(true) + assertTrue(state.loadingMore.value) + } + + @Test + fun `setLoadingFirstPage updates loading flow`() = runTest { + assertFalse(state.loading.value) + state.setLoadingFirstPage(true) + assertTrue(state.loading.value) + } + + @Test + fun `setCurrentRequest updates currentRequest flow`() = runTest { + assertNull(state.currentRequest.value) + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + state.setCurrentRequest(request) + assertEquals(request, state.currentRequest.value) + } + + @Test + fun `setEndOfChannels updates endOfChannels flow`() = runTest { + assertFalse(state.endOfChannels.value) + state.setEndOfChannels(true) + assertTrue(state.endOfChannels.value) + } + + @Test + fun `setRecoveryNeeded updates recoveryNeeded flow`() = runTest { + assertFalse(state.recoveryNeeded.value) + state.setRecoveryNeeded(true) + assertTrue(state.recoveryNeeded.value) + } + + @Test + fun `setChannelsOffset updates channelsOffset flow`() = runTest { + assertEquals(0, state.channelsOffset.value) + state.setChannelsOffset(42) + assertEquals(42, state.channelsOffset.value) + } + + @Test + fun `setChannels updates rawChannels and channels flow`() = runTest { + assertNull(state.rawChannels) + val channel = randomChannel() + state.setChannels(mapOf(channel.cid to channel)) + assertEquals(mapOf(channel.cid to channel), state.rawChannels) + advanceUntilIdle() + assertEquals(1, state.channels.value?.size) + } + + // endregion + + // region channelsStateData + + @Test + fun `channelsStateData emits Loading when loading is true`() = runTest { + state.setLoadingFirstPage(true) + advanceUntilIdle() + assertEquals(ChannelsStateData.Loading, state.channelsStateData.value) + } + + @Test + fun `channelsStateData emits Loading when channels are null`() = runTest { + // channels are null by default (never set), loading is false + state.setLoadingFirstPage(false) + advanceUntilIdle() + assertEquals(ChannelsStateData.Loading, state.channelsStateData.value) + } + + @Test + fun `channelsStateData emits OfflineNoResults when not loading and channels empty`() = runTest { + state.setChannels(emptyMap()) + state.setLoadingFirstPage(false) + advanceUntilIdle() + assertEquals(ChannelsStateData.OfflineNoResults, state.channelsStateData.value) + } + + @Test + fun `channelsStateData emits Result when channels available`() = runTest { + val channel = randomChannel() + state.setChannels(mapOf(channel.cid to channel)) + state.setLoadingFirstPage(false) + advanceUntilIdle() + val result = state.channelsStateData.value + assertTrue(result is ChannelsStateData.Result) + assertEquals(1, (result as ChannelsStateData.Result).channels.size) + } + + // endregion + + // region nextPageRequest + + @Test + fun `nextPageRequest is null when currentRequest is null`() = runTest { + advanceUntilIdle() + assertNull(state.nextPageRequest.value) + } + + @Test + fun `nextPageRequest combines currentRequest with channelsOffset and updates when offset changes`() = runTest { + val request = QueryChannelsRequest(filter = filter, offset = 0, limit = 30, querySort = sort) + state.setCurrentRequest(request) + state.setChannelsOffset(30) + advanceUntilIdle() + + val nextPage = state.nextPageRequest.value + assertEquals(30, nextPage?.offset) + + state.setChannelsOffset(60) + advanceUntilIdle() + assertEquals(60, state.nextPageRequest.value?.offset) + } + + // endregion + + // region currentLoading + + @Test + fun `currentLoading returns loading when channels are null or empty`() = runTest { + // channels null by default + state.setLoadingFirstPage(true) + assertTrue(state.currentLoading.value) + } + + @Test + fun `currentLoading returns loadingMore when channels are non-empty`() = runTest { + val channel = randomChannel() + state.setChannels(mapOf(channel.cid to channel)) + advanceUntilIdle() + state.setLoadingMore(true) + assertTrue(state.currentLoading.value) + } + + // endregion + + // region sortedChannels + + @Test + fun `channels returns sorted list per sort comparator`() = runTest { + val older = randomChannel(id = "older").copy(lastMessageAt = Date(1000)) + val newer = randomChannel(id = "newer").copy(lastMessageAt = Date(2000)) + // Sort is descByName("last_message_at"), so newer should come first + state.setChannels(mapOf(older.cid to older, newer.cid to newer)) + advanceUntilIdle() + val channels = state.channels.value!! + assertEquals(2, channels.size) + assertEquals(newer.cid, channels[0].cid) + assertEquals(older.cid, channels[1].cid) + } + + @Test + fun `channels update when latestUsers flow changes`() = runTest { + val user = randomUser(id = "user1", name = "Original") + val channel = randomChannel(id = "ch1").copy( + createdBy = user, + ) + state.setChannels(mapOf(channel.cid to channel)) + advanceUntilIdle() + + val updatedUser = user.copy(name = "Updated") + latestUsers.value = mapOf(updatedUser.id to updatedUser) + advanceUntilIdle() + + val result = state.channels.value!!.first() + assertEquals("Updated", result.createdBy.name) + } + + // endregion + + // region destroy + + @Test + fun `destroy nullifies flows and setters become no-ops`() = runTest { + state.destroy() + // After destroy, setters should not throw (they use ?. safe calls) + state.setLoadingMore(true) + state.setLoadingFirstPage(true) + state.setEndOfChannels(true) + state.setRecoveryNeeded(true) + state.setChannelsOffset(99) + // rawChannels should be null since _channels was nullified + assertNull(state.rawChannels) + } + + // endregion +}