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 122e9dc0036..2b3f050ae25 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.test import io.getstream.chat.android.client.events.AnswerCastedEvent import io.getstream.chat.android.client.events.ChannelDeletedEvent import io.getstream.chat.android.client.events.ChannelHiddenEvent +import io.getstream.chat.android.client.events.ChannelTruncatedEvent import io.getstream.chat.android.client.events.ChannelUpdatedByUserEvent import io.getstream.chat.android.client.events.ChannelUpdatedEvent import io.getstream.chat.android.client.events.ChannelUserBannedEvent @@ -199,6 +200,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 +228,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 +240,7 @@ public fun randomNotificationChannelDeletedEvent( channel = channel, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) } @@ -298,6 +321,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 +337,7 @@ public fun randomNotificationMarkReadEvent( thread = thread, unreadThreads = unreadThreads, unreadThreadMessages = unreadThreadMessages, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomNotificationMarkUnreadEvent( @@ -330,6 +355,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 +373,7 @@ public fun randomNotificationMarkUnreadEvent( unreadMessages = unreadMessages, lastReadMessageAt = lastReadMessageAt, lastReadMessageId = lastReadMessageId, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomTypingStopEvent( @@ -442,6 +469,7 @@ public fun randomNotificationMessageNewEvent( message: Message = randomMessage(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationMessageNewEvent = NotificationMessageNewEvent( type = EventType.NOTIFICATION_MESSAGE_NEW, createdAt = createdAt, @@ -453,6 +481,7 @@ public fun randomNotificationMessageNewEvent( message = message, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomMessageUpdateEvent( @@ -526,6 +555,7 @@ public fun randomNewMessageEvent( totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), channelMessageCount: Int? = positiveRandomInt(), + groupedUnreadChannels: Map? = null, ): NewMessageEvent { return NewMessageEvent( type = EventType.MESSAGE_NEW, @@ -540,6 +570,7 @@ public fun randomNewMessageEvent( totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, channelMessageCount = channelMessageCount, + groupedUnreadChannels = groupedUnreadChannels, ) } @@ -551,6 +582,7 @@ public fun randomNotificationChannelTruncatedEvent( channel: Channel = randomChannel(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): NotificationChannelTruncatedEvent = NotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, createdAt = createdAt, @@ -561,6 +593,7 @@ public fun randomNotificationChannelTruncatedEvent( channel = channel, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomMarkAllReadEvent( @@ -568,6 +601,7 @@ public fun randomMarkAllReadEvent( user: User = randomUser(), totalUnreadCount: Int = randomInt(), unreadChannels: Int = randomInt(), + groupedUnreadChannels: Map? = null, ): MarkAllReadEvent = MarkAllReadEvent( type = EventType.NOTIFICATION_MARK_READ, createdAt = createdAt, @@ -575,6 +609,7 @@ public fun randomMarkAllReadEvent( user = user, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) public fun randomReminderCreatedEvent( @@ -661,17 +696,18 @@ public fun randomQueryChannelsSpec( filter: FilterObject = NeutralFilterObject, sort: QuerySorter = QuerySortByField(), cids: Set = emptySet(), + groupKey: String? = null, predefinedFilterName: String? = null, predefinedFilterValues: Map? = null, predefinedSortValues: Map? = null, ): QueryChannelsSpec = QueryChannelsSpec( filter = filter, querySort = sort, - cids = cids, + groupKey = groupKey, predefinedFilterName = predefinedFilterName, predefinedFilterValues = predefinedFilterValues, predefinedSortValues = predefinedSortValues, -) +).also { it.cids = 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 7000eaa8926..763ab234c89 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/util/List;Ljava/lang/Integer;ZZ)Lio/getstream/result/call/Call; + public static synthetic fun queryGroupedChannels$default (Lio/getstream/chat/android/client/ChatClient;Ljava/util/List;Ljava/lang/Integer;ZZILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryMembers (Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;)Lio/getstream/result/call/Call; public static synthetic fun queryMembers$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;IILio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/List;ILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun queryPollVotes (Ljava/lang/String;Lio/getstream/chat/android/models/FilterObject;Ljava/lang/Integer;Ljava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/result/call/Call; @@ -1533,6 +1535,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; } @@ -1589,19 +1595,21 @@ public final class io/getstream/chat/android/client/events/HealthEvent : io/gets public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/MarkAllReadEvent : io/getstream/chat/android/client/events/ChatEvent, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/MarkAllReadEvent : io/getstream/chat/android/client/events/ChatEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/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; public final fun component4 ()Lio/getstream/chat/android/models/User; public final fun component5 ()I public final fun component6 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;II)Lio/getstream/chat/android/client/events/MarkAllReadEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/MarkAllReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/MarkAllReadEvent; + public final fun component7 ()Ljava/util/Map; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/Map;)Lio/getstream/chat/android/client/events/MarkAllReadEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/MarkAllReadEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/MarkAllReadEvent; public fun equals (Ljava/lang/Object;)Z 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; @@ -1799,13 +1807,14 @@ public final class io/getstream/chat/android/client/events/MessageUpdatedEvent : public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NewMessageEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasWatcherCount, io/getstream/chat/android/client/events/UserEvent { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NewMessageEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts, io/getstream/chat/android/client/events/HasWatcherCount, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I public final fun component11 ()I public final fun component12 ()Ljava/lang/Integer; + public final fun component13 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Lio/getstream/chat/android/models/User; @@ -1814,14 +1823,15 @@ public final class io/getstream/chat/android/client/events/NewMessageEvent : io/ public final fun component7 ()Ljava/lang/String; public final fun component8 ()Lio/getstream/chat/android/models/Message; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;)Lio/getstream/chat/android/client/events/NewMessageEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NewMessageEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NewMessageEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;)Lio/getstream/chat/android/client/events/NewMessageEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NewMessageEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;IIILjava/lang/Integer;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NewMessageEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannelId ()Ljava/lang/String; public final fun getChannelMessageCount ()Ljava/lang/Integer; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getMessage ()Lio/getstream/chat/android/models/Message; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I @@ -1863,10 +1873,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; @@ -1875,14 +1886,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; @@ -1908,10 +1920,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; @@ -1920,14 +1933,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; @@ -2015,15 +2029,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; @@ -2032,13 +2047,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; @@ -2053,9 +2069,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; @@ -2064,6 +2080,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; @@ -2072,14 +2089,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; @@ -2095,11 +2113,12 @@ public final class io/getstream/chat/android/client/events/NotificationMarkUnrea public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/client/events/NotificationMessageNewEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts { - public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;II)V - public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IIILkotlin/jvm/internal/DefaultConstructorMarker;)V +public final class io/getstream/chat/android/client/events/NotificationMessageNewEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasMessage, io/getstream/chat/android/client/events/HasUnreadCounts { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/lang/String; public final fun component10 ()I + public final fun component11 ()Ljava/util/Map; public final fun component2 ()Ljava/util/Date; public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; @@ -2108,14 +2127,15 @@ public final class io/getstream/chat/android/client/events/NotificationMessageNe public final fun component7 ()Lio/getstream/chat/android/models/Channel; public final fun component8 ()Lio/getstream/chat/android/models/Message; public final fun component9 ()I - public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;II)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMessageNewEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IIILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationMessageNewEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;IILjava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationMessageNewEvent; public fun equals (Ljava/lang/Object;)Z public fun getChannel ()Lio/getstream/chat/android/models/Channel; public fun getChannelId ()Ljava/lang/String; public fun getChannelType ()Ljava/lang/String; public fun getCid ()Ljava/lang/String; public fun getCreatedAt ()Ljava/util/Date; + public fun getGroupedUnreadChannels ()Ljava/util/Map; public fun getMessage ()Lio/getstream/chat/android/models/Message; public fun getRawCreatedAt ()Ljava/lang/String; public fun getTotalUnreadCount ()I @@ -2822,6 +2842,17 @@ 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 getGroupKey ()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$Predefined : io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier { public fun (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V public final fun component1 ()Ljava/lang/String; @@ -3096,8 +3127,10 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract fun insertQueryChannels (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectBy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectBy (Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun selectBy (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun selectBy$suspendImpl (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun selectBy$suspendImpl (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun selectBy$suspendImpl (Lio/getstream/chat/android/client/persistance/repository/QueryChannelsRepository;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/persistance/repository/ReactionRepository { @@ -3174,7 +3207,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; @@ -3257,6 +3290,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; @@ -3399,6 +3434,12 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener public static synthetic fun onQueryChannelsResultWithPredefinedFilter$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener { + public fun onQueryGroupedChannelsRequest (Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryGroupedChannelsRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun onQueryGroupedChannelsResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/util/Map;ZZLkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryMembersListener { 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; } @@ -3508,21 +3549,22 @@ public final class io/getstream/chat/android/client/query/CreateChannelParams { public final class io/getstream/chat/android/client/query/QueryChannelsSpec { public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)V - public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V - public synthetic fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/FilterObject; public final fun component2 ()Lio/getstream/chat/android/models/querysort/QuerySorter; - public final fun component3 ()Ljava/util/Set; + public final fun component3 ()Ljava/lang/String; public final fun component4 ()Ljava/lang/String; public final fun component5 ()Ljava/util/Map; public final fun component6 ()Ljava/util/Map; public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; - public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; + public final fun copy (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; public static synthetic fun copy$default (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;ILjava/lang/Object;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/query/QueryChannelsSpec;Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;Ljava/util/Set;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; + public 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/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lio/getstream/chat/android/client/query/QueryChannelsSpec; public fun equals (Ljava/lang/Object;)Z public final fun getCids ()Ljava/util/Set; public final fun getFilter ()Lio/getstream/chat/android/models/FilterObject; + public final fun getGroupKey ()Ljava/lang/String; public final fun getPredefinedFilterName ()Ljava/lang/String; public final fun getPredefinedFilterValues ()Ljava/util/Map; public final fun getPredefinedSortValues ()Ljava/util/Map; 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 7aff7b86432..0407cd52633 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -185,6 +185,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 @@ -3148,6 +3150,74 @@ internal constructor( } } + /** + * Queries channels grouped into the specified groups and returns the first page of each. + * + * **IMPORTANT: This is an enterprise feature and is disabled by default. For more info, reach out to our + * Contact & Support.** + * + * @param groups The group names to fetch. Required; must contain at least one group name. + * Duplicate names are silently de-duplicated. + * @param limit Default max channels per group. Accepted range is `1..10`. `null` uses the + * server default. + * @param watch Whether to start watching the returned channels for real-time events. + * @param presence Whether to receive presence events for the members of the returned channels. + * + * @return A [Call] containing a [GroupedChannels] with per-group channels and cursors. + */ + @CheckResult + public fun queryGroupedChannels( + groups: List, + limit: Int? = null, + watch: Boolean = true, + presence: Boolean = false, + ): Call = queryGroupedChannelsInternal( + limit = limit, + groups = groups.associateWith { GroupedChannelsGroupQuery() }, + watch = watch, + presence = presence, + ) + + /** + * Internal variant of [queryGroupedChannels] that accepts a per-group configuration map. + * + * 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. + * + * **IMPORTANT: This is an enterprise feature and is disabled by default. For more info, reach out to our + * Contact & Support.** + * + * @param limit Default max channels per group when a group does not specify its own limit. + * Accepted range is `1..10`. `null` uses the server default. + * @param groups Optional per-group configuration keyed by group name. `null` returns the + * first pages of 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 + @InternalStreamChatApi + public fun queryGroupedChannelsInternal( + limit: Int? = null, + groups: Map? = null, + watch: Boolean = true, + presence: Boolean = false, + ): Call { + return api.queryGroupedChannels(limit = limit, groups = groups, watch = watch, presence = presence) + .doOnStart(userScope) { + plugins.forEach { plugin -> + plugin.onQueryGroupedChannelsRequest(limit, groups, watch, 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 713b7cbfe5a..797fbe397d6 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt @@ -38,6 +38,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 @@ -289,6 +291,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 b8b7e1faff7..73033240615 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 @@ -76,6 +76,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 @@ -129,6 +131,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 @@ -1334,6 +1339,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..2a30dae7f77 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 @@ -487,6 +487,7 @@ internal class EventMapping( totalUnreadCount = total_unread_count, unreadChannels = unread_channels, channelMessageCount = channel_message_count, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -522,6 +523,7 @@ internal class EventMapping( channel = channel.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -553,6 +555,7 @@ internal class EventMapping( channel = channel.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -625,6 +628,7 @@ internal class EventMapping( unreadThreads = unread_threads, unreadThreadMessages = unread_thread_messages, lastReadMessageId = last_read_message_id, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -640,14 +644,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, ) } @@ -662,6 +667,7 @@ internal class EventMapping( user = user.toDomain(), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } @@ -680,6 +686,7 @@ internal class EventMapping( message = message.toDomain(channel.toChannelInfo()), totalUnreadCount = total_unread_count, unreadChannels = unread_channels, + groupedUnreadChannels = grouped_unread_channels, ) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index 69a14f19241..d214cfbc5f2 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 @@ -197,6 +197,7 @@ internal data class NewMessageEventDto( val total_unread_count: Int = 0, val unread_channels: Int = 0, val channel_message_count: Int? = null, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -236,6 +237,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 +257,7 @@ internal data class NotificationChannelTruncatedEventDto( val channel: DownstreamChannelDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -307,6 +310,7 @@ internal data class NotificationMarkReadEventDto( val unread_threads: Int? = null, val unread_thread_messages: Int? = null, val last_read_message_id: String?, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -321,10 +325,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) @@ -334,6 +339,7 @@ internal data class MarkAllReadEventDto( val user: DownstreamUserDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) @@ -347,6 +353,7 @@ internal data class NotificationMessageNewEventDto( val message: DownstreamMessageDto, val total_unread_count: Int = 0, val unread_channels: Int = 0, + val grouped_unread_channels: Map? = null, ) : ChatEventDto() @JsonClass(generateAdapter = true) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/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..083ede23e4c 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 */ @@ -355,7 +372,8 @@ public data class NewMessageEvent( override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, val channelMessageCount: Int?, -) : CidEvent(), UserEvent, HasMessage, HasWatcherCount, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), UserEvent, HasMessage, HasWatcherCount, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when the user is added to the list of channel members @@ -386,7 +404,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 +430,8 @@ public data class NotificationChannelTruncatedEvent( override val channel: Channel, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : CidEvent(), HasChannel, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), HasChannel, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when the user accepts an invite @@ -475,7 +495,8 @@ public data class NotificationMarkReadEvent( val thread: ThreadInfo? = null, override val unreadThreads: Int? = null, override val unreadThreadMessages: Int? = null, -) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts, HasGroupedUnreadChannels /** * Triggered when the the user mark as unread a conversation from a particular message @@ -497,7 +518,8 @@ public data class NotificationMarkUnreadEvent( val threadId: String? = null, override val unreadThreads: Int = 0, override val unreadThreadMessages: Int? = null, -) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), UserEvent, HasUnreadCounts, HasUnreadThreadCounts, HasGroupedUnreadChannels /** * Triggered when the total count of unread messages (across all channels the user is a member) changes @@ -509,7 +531,8 @@ public data class MarkAllReadEvent( override val user: User, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : ChatEvent(), UserEvent, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : ChatEvent(), UserEvent, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when a message is added to a channel @@ -525,7 +548,8 @@ public data class NotificationMessageNewEvent( override val message: Message, override val totalUnreadCount: Int = 0, override val unreadChannels: Int = 0, -) : CidEvent(), HasChannel, HasMessage, HasUnreadCounts + override val groupedUnreadChannels: Map? = null, +) : CidEvent(), HasChannel, HasMessage, HasUnreadCounts, HasGroupedUnreadChannels /** * Triggered when a message is added to a channel as a thread reply. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/QueryChannelsIdentifier.kt index 973453c9425..da13376874c 100644 --- 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 @@ -29,11 +29,13 @@ import io.getstream.chat.android.models.querysort.QuerySorter * the same query consistently maps to the same logic/state instance and the same persisted row * across runs. * - * Two shapes are supported: + * Three shapes are supported: * - [Standard] for classic queries where the client knows `filter` + `querySort` upfront. * - [Predefined] for server-side predefined filters where the actual `filter` and `querySort` are * only learned from the response. Identity must therefore be the predefined name plus the * interpolation values, since those are the only stable inputs available before the response. + * - [Grouped] for cursor-paginated grouped channel queries; identity is the stable [Grouped.groupKey] + * returned by the server. */ @InternalStreamChatApi public sealed interface QueryChannelsIdentifier { @@ -56,13 +58,23 @@ public sealed interface QueryChannelsIdentifier { val filterValues: Map?, val sortValues: Map?, ) : QueryChannelsIdentifier + + /** + * Identity for a grouped query channels request: identity is the stable [groupKey] returned by + * the server. Grouped queries use cursor-based pagination and a separate endpoint + * ([io.getstream.chat.android.client.ChatClient.queryGroupedChannels]). + */ + public data class Grouped( + val groupKey: String, + ) : QueryChannelsIdentifier } /** * Derives the [QueryChannelsIdentifier] from a [QueryChannelsRequest]. A non-null * [QueryChannelsRequest.predefinedFilter] marks the request as a predefined-filter query and * yields [QueryChannelsIdentifier.Predefined]; otherwise yields [QueryChannelsIdentifier.Standard] - * from the explicit `filter`/`querySort`. + * from the explicit `filter`/`querySort`. Grouped identifiers are not derivable from a + * [QueryChannelsRequest] — they are constructed directly by callers. */ @InternalStreamChatApi public val QueryChannelsRequest.identifier: QueryChannelsIdentifier @@ -76,20 +88,21 @@ public val QueryChannelsRequest.identifier: QueryChannelsIdentifier } /** - * Derives the [QueryChannelsIdentifier] from a [QueryChannelsSpec]. A non-null - * [QueryChannelsSpec.predefinedFilterName] marks the spec as a predefined-filter query and yields - * [QueryChannelsIdentifier.Predefined]; otherwise yields [QueryChannelsIdentifier.Standard] from - * the resolved `filter`/`querySort`. + * Derives the [QueryChannelsIdentifier] from a [QueryChannelsSpec]. Resolution order: + * - Non-null [QueryChannelsSpec.predefinedFilterName] → [QueryChannelsIdentifier.Predefined]. + * - Non-null [QueryChannelsSpec.groupKey] → [QueryChannelsIdentifier.Grouped]. + * - Otherwise → [QueryChannelsIdentifier.Standard] from the resolved `filter`/`querySort`. */ @InternalStreamChatApi public val QueryChannelsSpec.identifier: QueryChannelsIdentifier - get() = when (val name = predefinedFilterName) { - null -> QueryChannelsIdentifier.Standard(filter, querySort) - else -> QueryChannelsIdentifier.Predefined( - name = name, + get() = when { + predefinedFilterName != null -> QueryChannelsIdentifier.Predefined( + name = predefinedFilterName!!, filterValues = predefinedFilterValues.normalizedIdentifierValues(), sortValues = predefinedSortValues.normalizedIdentifierValues(), ) + groupKey != null -> QueryChannelsIdentifier.Grouped(groupKey!!) + else -> QueryChannelsIdentifier.Standard(filter, querySort) } private fun Map?.normalizedIdentifierValues(): Map? = 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 c874b02cc71..7c99fcf3e38 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 @@ -57,6 +57,11 @@ public interface QueryChannelsRepository { sortValues: Map?, ): QueryChannelsSpec? = null + /** + * Selects the spec stored under [groupKey] for a grouped query. + */ + public suspend fun selectBy(groupKey: String): QueryChannelsSpec? = null + /** * Clear QueryChannels of this repository. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpQueryChannelsRepository.kt index ac1af8270fe..be65b48e813 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 selectBy( predefinedFilterName: String, 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..9bee16dad73 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryGroupedChannelsListener.kt @@ -0,0 +1,63 @@ +/* + * 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 before the query grouped channels request is dispatched to the API. Fires regardless + * of whether the call later succeeds or fails, so the state plugin can persist the request + * parameters early and recover them even when the response never lands. + * + * @param limit The request-level default per-group limit, or `null` for the server default. + * @param groups The per-group request options being sent, or `null` when the request asks for + * the server-defined default set of groups. + * @param watch Whether watching was requested. + * @param presence Whether presence was requested. + */ + public suspend fun onQueryGroupedChannelsRequest( + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ) { /* No-Op */ } + + /** + * 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 3b2eb44d27e..f4da455a04c 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 @@ -23,36 +23,54 @@ import io.getstream.chat.android.models.querysort.QuerySorter /** * Spec describing a query channels operation and the channel CIDs that belong to it. * - * For predefined-filter queries the [predefinedFilterName] plus value maps form the spec's stable - * identity in the offline DB and must not change once assigned. [filter] and [querySort] are the - * *currently resolved* values for this spec instance — for predefined queries the resolved values - * are captured by replacing the held spec instance (see - * `QueryChannelsMutableState.applyResolvedSpec`). + * Three identity flavors are supported: + * - Standard queries: identity is `(filter, querySort)`. + * - Predefined queries: identity is [predefinedFilterName] plus the interpolation value maps. The + * [filter] and [querySort] held by this spec instance are the *currently resolved* values for + * that predefined query (captured by replacing the held spec instance — see + * `QueryChannelsMutableState.applyResolvedSpec`). + * - Grouped queries: identity is [groupKey], the stable key returned by the server's grouped + * channels endpoint. [filter] and [querySort] hold neutral placeholders for these queries. + * + * [cids] is intentionally a body `var` and not part of the primary constructor, so it does not + * participate in [equals]/[hashCode]/auto-generated `copy()` — it is treated as mutable spec + * payload rather than identity. * * The 2-arg [constructor] and 2-arg [copy] are kept for binary compatibility with callers that - * predate the predefined-filter fields. They delegate to the primary constructor with the - * predefined fields defaulted to their empty/null values. + * predate the variant-specific fields. They delegate to the primary constructor with the extras + * defaulted to their empty/null values. */ public data class QueryChannelsSpec( val filter: FilterObject, val querySort: QuerySorter, - var cids: Set = emptySet(), + val groupKey: String? = null, val predefinedFilterName: String? = null, val predefinedFilterValues: Map? = null, val predefinedSortValues: Map? = null, ) { + + /** + * CIDs of channels currently associated with this query. + */ + var cids: Set = emptySet() + public constructor( filter: FilterObject, querySort: QuerySorter, - ) : this(filter, querySort, emptySet(), null, null, null) + ) : this(filter, querySort, null, null, null, null) + /** + * Returns a new [QueryChannelsSpec] with [filter] and [querySort] replaced. Variant-specific + * fields ([groupKey], [predefinedFilterName], [predefinedFilterValues], [predefinedSortValues]) + * are carried over from the receiver; [cids] is not. + */ public fun copy( filter: FilterObject = this.filter, querySort: QuerySorter = this.querySort, ): QueryChannelsSpec = QueryChannelsSpec( filter = filter, querySort = querySort, - cids = cids, + groupKey = groupKey, predefinedFilterName = predefinedFilterName, predefinedFilterValues = predefinedFilterValues, predefinedSortValues = predefinedSortValues, 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..bb66076b0d9 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientGroupedChannelsApiTests.kt @@ -0,0 +1,247 @@ +/* + * 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.core.internal.InternalStreamChatApi +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.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +/** + * Tests for the [ChatClient.queryGroupedChannels] endpoint. + */ +@OptIn(InternalStreamChatApi::class) +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(groups = listOf("direct")).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(groups = listOf("direct")).await() + // then + verifyNetworkError(result, errorCode) + } + + @Test + fun `queryGroupedChannelsInternal dispatches request to plugin listeners before issuing the call`() = runTest { + // given + val plugin: Plugin = mock() + plugins.add(plugin) + val groupedChannels = GroupedChannels( + groups = mapOf( + "direct" to GroupedChannelsGroup( + groupKey = "direct", + channels = listOf(randomChannel()), + ), + ), + ) + val sut = Fixture() + .givenQueryGroupedChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall()) + .get() + val groupsParam = mapOf("direct" to GroupedChannelsGroupQuery(limit = 25)) + // when + sut.queryGroupedChannelsInternal( + limit = 30, + groups = groupsParam, + watch = true, + presence = false, + ).await() + // then - the request hook fires BEFORE the result hook + inOrder(plugin) { + verify(plugin).onQueryGroupedChannelsRequest( + limit = eq(30), + groups = eq(groupsParam), + watch = eq(true), + presence = eq(false), + ) + verify(plugin).onQueryGroupedChannelsResult( + result = any(), + limit = eq(30), + groups = eq(groupsParam), + watch = eq(true), + presence = eq(false), + ) + } + } + + @Test + fun `queryGroupedChannelsInternal dispatches request hook even when the call fails`() = runTest { + // given + val plugin: Plugin = mock() + plugins.add(plugin) + val errorCode = positiveRandomInt() + val sut = Fixture() + .givenQueryGroupedChannelsResult(RetroError(errorCode).toRetrofitCall()) + .get() + val groupsParam = mapOf("direct" to GroupedChannelsGroupQuery(limit = 25)) + // when + sut.queryGroupedChannelsInternal( + limit = 30, + groups = groupsParam, + watch = true, + presence = false, + ).await() + // then - request hook ran, giving the state plugin a chance to capture the config + // before the result hook reports the failure + verify(plugin).onQueryGroupedChannelsRequest( + limit = eq(30), + groups = eq(groupsParam), + watch = eq(true), + presence = eq(false), + ) + } + + @Test + fun `queryGroupedChannelsInternal 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.queryGroupedChannelsInternal( + 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), + ) + } + + @Test + fun `public queryGroupedChannels delegates to internal with default per-group queries and fires hooks once`() = runTest { + // given + val plugin: Plugin = mock() + plugins.add(plugin) + val groupedChannels = GroupedChannels( + groups = mapOf( + "direct" to GroupedChannelsGroup( + groupKey = "direct", + channels = listOf(randomChannel()), + ), + ), + ) + val sut = Fixture() + .givenQueryGroupedChannelsResult(RetroSuccess(groupedChannels).toRetrofitCall()) + .get() + val expectedGroups = mapOf("direct" to GroupedChannelsGroupQuery()) + // when - public entry point: a list of group names, no per-group pagination + sut.queryGroupedChannels( + groups = listOf("direct"), + limit = 30, + watch = true, + presence = false, + ).await() + // then - hooks fire exactly once, with each requested group mapped to a default + // GroupedChannelsGroupQuery, ordered request before result + inOrder(plugin) { + verify(plugin, times(1)).onQueryGroupedChannelsRequest( + limit = eq(30), + groups = eq(expectedGroups), + watch = eq(true), + presence = eq(false), + ) + verify(plugin, times(1)).onQueryGroupedChannelsResult( + result = any(), + limit = eq(30), + groups = eq(expectedGroups), + 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 9d458079045..f2a62c2d693 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 @@ -1086,6 +1092,7 @@ internal fun createMarkAllReadEventStringJson() = "total_unread_count":0, "created_at":"2020-06-29T06:14:28.000Z", "type":"notification.mark_read", - "user":${createUserJsonString()} + "user":${createUserJsonString()}, + "grouped_unread_channels": {"direct": 2, "support": 5} } """.trimIndent() 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 663a93094aa..1e91a0c395f 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 @@ -65,6 +65,8 @@ 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.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 @@ -102,6 +104,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 @@ -133,6 +137,7 @@ import io.getstream.chat.android.client.utils.verifySuccess import io.getstream.chat.android.models.BannedUsersSort import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters +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 @@ -1897,6 +1902,109 @@ 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) + } + + @Test + fun `queryGroupedChannels maps per-group GroupedChannelsGroupQuery into the request body`() = runTest { + val response = QueryGroupedChannelsResponse(groups = emptyMap(), duration = "0ms") + val api = mock() + whenever(api.queryGroupedChannels(any(), any())).doReturn(RetroSuccess(response).toRetrofitCall()) + val sut = Fixture() + .withChannelApi(api) + .get() + val connectionId = randomString() + sut.setConnection(userId = randomString(), connectionId = connectionId) + + sut.queryGroupedChannels( + limit = 30, + groups = mapOf( + "direct" to GroupedChannelsGroupQuery(limit = 10, next = "cursor-next", prev = null), + "support" to GroupedChannelsGroupQuery(limit = null, next = null, prev = "cursor-prev"), + ), + watch = false, + presence = false, + ).await() + + val expectedPayload = QueryGroupedChannelsRequest( + limit = 30, + groups = mapOf( + "direct" to QueryGroupedChannelsGroupRequest(limit = 10, next = "cursor-next", prev = null), + "support" to QueryGroupedChannelsGroupRequest(limit = null, next = null, prev = "cursor-prev"), + ), + watch = false, + presence = false, + ) + verify(api, times(1)).queryGroupedChannels(connectionId, expectedPayload) + } + + @Test + fun `queryGroupedChannels maps null unread_channels to 0 in the domain result`() = runTest { + val response = QueryGroupedChannelsResponse( + groups = mapOf( + "direct" to QueryGroupedChannelsGroup( + channels = emptyList(), + unread_channels = null, + next = null, + prev = null, + ), + ), + duration = "0ms", + ) + val api = mock() + whenever(api.queryGroupedChannels(any(), any())).doReturn(RetroSuccess(response).toRetrofitCall()) + val sut = Fixture() + .withChannelApi(api) + .get() + sut.setConnection(userId = randomString(), connectionId = randomString()) + + val result = sut.queryGroupedChannels(limit = null, groups = null, watch = false, presence = false).await() + + result `should be instance of` Result.Success::class + val grouped = (result as Result.Success).value + assertEquals(0, grouped.groups.getValue("direct").unreadChannels) + } + + @Test + fun `queryGroupedChannels postpones the call when watch is true and connectionId is blank`() = runTest { + val api = mock() + val sut = Fixture() + .withChannelApi(api) + .get() + // Intentionally not calling setConnection — connectionId stays blank. + + sut.queryGroupedChannels(limit = null, groups = null, watch = true, presence = false) + + // Postponed calls do not invoke the API eagerly; they wait for a connection. + verify(api, never()).queryGroupedChannels(any(), any()) + } + @ParameterizedTest @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#queryChannelsWithPredefinedFilterInput") fun testQueryChannelsWithPredefinedFilter(call: RetrofitCall, expected: KClass<*>) = 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 b41780d9697..1ba03260700 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 @@ -50,6 +50,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 @@ -73,6 +75,7 @@ import io.getstream.chat.android.models.UnreadChannelByType import io.getstream.chat.android.models.UnreadCounts import io.getstream.chat.android.models.UnreadThread import io.getstream.chat.android.models.UploadedFile +import io.getstream.chat.android.positiveRandomInt import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomDate import io.getstream.chat.android.randomDateOrNull @@ -439,6 +442,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 = positiveRandomInt(), + next = null, + prev = null, + ), + ), + duration = "12ms", + ), + ).toRetrofitCall(), + Result.Success::class, + ), + Arguments.of( + RetroError(statusCode = 500).toRetrofitCall(), + Result.Failure::class, + ), + ) + @JvmStatic fun queryChannelsWithPredefinedFilterInput() = listOf( Arguments.of( 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..f658b38df10 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -204,6 +204,7 @@ internal object EventMappingTestArguments { private val UNREAD_MESSAGES = positiveRandomInt() private val TOTAL_UNREAD_COUNT = positiveRandomInt() private val UNREAD_CHANNELS = positiveRandomInt() + private val GROUPED_UNREAD_CHANNELS = mapOf("direct" to positiveRandomInt(), "support" to positiveRandomInt()) private val UNREAD_THREADS = positiveRandomInt() private val UNREAD_THREAD_MESSAGES = positiveRandomInt() private val REACTION = Mother.randomDownstreamReactionDto() @@ -230,6 +231,7 @@ internal object EventMappingTestArguments { image = CHANNEL_IMAGE, ), message = MESSAGE_WITHOUT_CHANNEL_INFO, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val draftMessageUpdatedDto = DraftMessageUpdatedEventDto( @@ -379,6 +381,7 @@ internal object EventMappingTestArguments { type = EventType.NOTIFICATION_MARK_READ, created_at = EXACT_DATE, user = USER, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val memberAddedDto = MemberAddedEventDto( @@ -471,6 +474,7 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationChannelMutesUpdatesDto = NotificationChannelMutesUpdatedEventDto( @@ -486,6 +490,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 +533,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 +549,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 +560,7 @@ internal object EventMappingTestArguments { channel_id = CHANNEL_ID, message = MESSAGE, channel = CHANNEL, + grouped_unread_channels = GROUPED_UNREAD_CHANNELS, ) private val notificationThreadMessageNewDto = NotificationThreadMessageNewEventDto( @@ -838,6 +846,7 @@ internal object EventMappingTestArguments { totalUnreadCount = newMessageDto.total_unread_count, unreadChannels = newMessageDto.unread_channels, channelMessageCount = newMessageDto.channel_message_count, + groupedUnreadChannels = newMessageDto.grouped_unread_channels, ) private val draftMessageUpdatedEvent = DraftMessageUpdatedEvent( @@ -1012,6 +1021,7 @@ internal object EventMappingTestArguments { createdAt = markAllReadDto.created_at.date, rawCreatedAt = markAllReadDto.created_at.rawDate, user = with(domainMapping) { markAllReadDto.user.toDomain() }, + groupedUnreadChannels = markAllReadDto.grouped_unread_channels, ) private val memberAdded = MemberAddedEvent( @@ -1119,6 +1129,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationChannelDeletedDto.channel.toDomain() }, + groupedUnreadChannels = notificationChannelDeletedDto.grouped_unread_channels, ) private val notificationChannelMutesUpdates = NotificationChannelMutesUpdatedEvent( @@ -1138,6 +1149,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationChannelTruncatedDto.channel.toDomain() }, + groupedUnreadChannels = notificationChannelTruncatedDto.grouped_unread_channels, ) private val notificationInviteAccepted = NotificationInviteAcceptedEvent( @@ -1188,6 +1200,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 +1215,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 +1231,7 @@ internal object EventMappingTestArguments { channel = with(domainMapping) { notificationMessageNewDto.channel.toDomain() }, + groupedUnreadChannels = notificationMessageNewDto.grouped_unread_channels, ) private val notificationThreadMessageNew = NotificationThreadMessageNewEvent( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index 94fa9dd6fc5..7a5d518537b 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 @@ -183,6 +183,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", @@ -541,6 +542,7 @@ internal object EventArguments { channelType = channelType, channelId = channelId, channel = channel, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationChannelTruncatedEvent = NotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, @@ -594,6 +596,7 @@ internal object EventArguments { totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, lastReadMessageId = message.id, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationMarkUnreadEvent = NotificationMarkUnreadEvent( type = EventType.NOTIFICATION_MARK_UNREAD, @@ -609,6 +612,7 @@ internal object EventArguments { firstUnreadMessageId = message.id, lastReadMessageAt = date, lastReadMessageId = parentMessageId, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationMessageNewEvent = NotificationMessageNewEvent( type = EventType.NOTIFICATION_MESSAGE_NEW, @@ -621,6 +625,7 @@ internal object EventArguments { message = message, totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, + groupedUnreadChannels = groupedUnreadChannels, ) private val notificationRemovedFromChannelEvent = NotificationRemovedFromChannelEvent( type = EventType.NOTIFICATION_REMOVED_FROM_CHANNEL, @@ -794,6 +799,7 @@ internal object EventArguments { totalUnreadCount = totalUnreadCount, unreadChannels = unreadChannels, channelMessageCount = 1, + groupedUnreadChannels = groupedUnreadChannels, ) private val newMessageWithoutUnreadCountsEvent = NewMessageEvent( type = EventType.MESSAGE_NEW, @@ -826,6 +832,7 @@ internal object EventArguments { createdAt = date, rawCreatedAt = streamDateFormatter.format(date), user = user, + groupedUnreadChannels = groupedUnreadChannels, ) private val connectionErrorEvent = ConnectionErrorEvent( type = EventType.CONNECTION_ERROR, 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 544c78830da..7c4f15d949a 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 fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;JZLio/getstream/chat/android/models/querysort/QuerySorter;Lkotlinx/coroutines/flow/Flow;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun archiveChannel (Lio/getstream/chat/android/models/Channel;)V @@ -4950,6 +4952,8 @@ public final class io/getstream/chat/android/compose/viewmodel/channels/ChannelV public fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V public synthetic fun (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Integer;Ljava/lang/Integer;Lio/getstream/chat/android/state/event/handler/chat/factory/ChatEventHandlerFactory;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;ZLio/getstream/chat/android/models/querysort/QuerySorter;)V + public synthetic fun (Ljava/lang/String;Lio/getstream/chat/android/client/ChatClient;ZLio/getstream/chat/android/models/querysort/QuerySorter;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun create (Ljava/lang/Class;)Landroidx/lifecycle/ViewModel; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelListViewModel.kt index c0e9dea9a55..92e6c812980 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,14 +38,16 @@ 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 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.grouped.internal.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 @@ -74,10 +77,9 @@ import kotlinx.coroutines.plus 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. + * ViewModel managing the state of a given Channel List. * - * @param chatClient Used to connect to the API. + * @param chatClient The prepared [ChatClient] instance required for fetching the data. * @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. @@ -90,7 +92,7 @@ import kotlin.coroutines.cancellation.CancellationException * @param globalState A flow emitting the current [GlobalState]. */ @OptIn(ExperimentalCoroutinesApi::class) -@Suppress("TooManyFunctions", "LongParameterList") +@Suppress("TooManyFunctions", "LongParameterList", "LargeClass") public class ChannelListViewModel internal constructor( public val chatClient: ChatClient, private val mode: QueryMode, @@ -104,12 +106,39 @@ public class ChannelListViewModel internal constructor( private val globalState: Flow, ) : ViewModel() { + /** Internal discriminator for the query modes supported by this ViewModel. */ + internal sealed interface QueryMode { + data class Standard( + val initialFilter: FilterObject?, + val initialSort: QuerySorter, + ) : QueryMode + + data class Predefined( + val name: String, + val filterValues: Map?, + val sortValues: Map?, + ) : QueryMode + + data class Grouped(val groupKey: String) : QueryMode + } + /** * Creates a view model that queries channels by an explicit filter and sort. * + * @param chatClient The prepared [ChatClient] instance required for fetching the data. * @param initialSort The initial sort used for [Channel]s. Can be changed at runtime via [setQuerySort]. - * @param initialFilters The data filter. Can be changed at runtime via [setFilters]. When `null`, - * a default filter scoped to messaging channels the current user is a member of is used. + * @param initialFilters The data filter. When `null`, a default filter scoped to "messaging" channels the current + * user is a member of is used. Can be changed at runtime via [setFilters]. + * @param 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 searchDebounceMs The debounce time for search queries. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. + * @param globalState A flow emitting the current [GlobalState]. */ public constructor( chatClient: ChatClient, @@ -141,12 +170,22 @@ public class ChannelListViewModel internal constructor( * * The filter and sort are identified by [predefinedFilterName] and resolved server-side; * [filterValues] and [sortValues] interpolate into the predefined template. [setFilters] and - * [setQuerySort] do not affect a view model created this way. Channel search still narrows the - * displayed list to the search predicate. + * [setQuerySort] do not affect a view model created this way. * + * @param chatClient The prepared [ChatClient] instance required for fetching the data. * @param predefinedFilterName The name of the predefined filter registered on the backend. * @param filterValues Optional values interpolated into the predefined filter template. * @param sortValues Optional values interpolated into the predefined sort template. + * @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 searchDebounceMs The debounce time for search queries. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. + * @param globalState A flow emitting the current [GlobalState]. */ public constructor( chatClient: ChatClient, @@ -178,6 +217,43 @@ public class ChannelListViewModel internal constructor( 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. + * + * **IMPORTANT: This is an enterprise feature and is disabled by default. For more info, reach out to our + * Contact & Support.** + * + * @param chatClient The prepared [ChatClient] instance required for fetching the data. + * @param groupKey The name of the channels group. + * @param searchDebounceMs The debounce time for search queries. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. + * @param globalState A flow emitting the current [GlobalState]. + */ + 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, + ) + private val logger by taggedLogger("Chat:ChannelListVM") /** @@ -201,24 +277,26 @@ public class ChannelListViewModel internal constructor( private val queryChannelDebouncer = Debouncer(searchDebounceMs, chListScope) /** - * State flow that keeps the value of the current [FilterObject] for channels. Only meaningful in - * [QueryMode.Standard]; remains `null` in [QueryMode.Predefined] (the server owns the filter). + * Keeps track of the current filter. Only relevant for [QueryMode.Standard], the other modes follow a server-side + * filtering spec. */ private val filterFlow: MutableStateFlow = MutableStateFlow( when (mode) { is QueryMode.Standard -> mode.initialFilter is QueryMode.Predefined -> null + is QueryMode.Grouped -> null }, ) /** - * State flow that keeps the value of the current [QuerySorter] for channels. Only meaningful in - * [QueryMode.Standard]; in [QueryMode.Predefined] it carries an inert default (the server owns the sort). + * Keeps track of the current sort spec. Only relevant for [QueryMode.Standard], the other modes follow a + * server-side sorting spec. */ private val querySortFlow: MutableStateFlow> = MutableStateFlow( when (mode) { is QueryMode.Standard -> mode.initialSort is QueryMode.Predefined -> QuerySortByField() + is QueryMode.Grouped -> QuerySortByField.descByName("last_updated") }, ) @@ -319,8 +397,26 @@ public class ChannelListViewModel internal constructor( */ private val searchMessageState: MutableStateFlow = MutableStateFlow(null) + /** + * Tracks the most recent next-page request issued by [loadMoreQueryChannels] so we can dedupe + * repeated load-more clicks against an identical paginated request. + */ 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; Predefined and Grouped modes + * have server-owned filter/sort, so they only react to search and refresh. + */ + private val activeQuery: Flow = when (mode) { + is QueryMode.Standard -> + combine(_searchQuery, queryConfigFlow, refreshFlow) { query, _, _ -> query } + is QueryMode.Predefined -> + combine(_searchQuery, 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. */ @@ -338,15 +434,8 @@ public class ChannelListViewModel internal constructor( } } - /** - * Makes the initial query to request channels and starts observing state changes. - */ private suspend fun init() { logger.d { "[init] no args" } - val activeQuery: Flow = when (mode) { - is QueryMode.Standard -> combine(_searchQuery, queryConfigFlow, refreshFlow) { query, _, _ -> query } - is QueryMode.Predefined -> combine(_searchQuery, refreshFlow) { query, _ -> query } - } activeQuery.collectLatest { query -> logger.i { "[observeInit] query: $query" } when (query) { @@ -354,7 +443,22 @@ public class ChannelListViewModel internal constructor( is SearchQuery.Channels, -> { searchScope.coroutineContext.cancelChildren() - observeQueryChannels(query.query) + when (mode) { + is QueryMode.Standard, + is QueryMode.Predefined, + -> { + // Standard QueryChannels + observeQueryChannels(query.query) + } + is QueryMode.Grouped -> + if (query.query.length >= MIN_CHANNEL_SEARCH_QUERY_LENGTH) { + // Standard QueryChannels (with purpose of searching channels) + observeQueryChannels(query.query) + } else { + // GroupedQueryChannels -> just observe underlying state + observeGroupedChannels(mode.groupKey) + } + } } is SearchQuery.Messages -> { chListScope.coroutineContext.cancelChildren() @@ -484,19 +588,43 @@ public class ChannelListViewModel internal constructor( } } - @Suppress("LongMethod") - private fun observeQueryChannels(searchQuery: String) = runCatching { - queryChannelDebouncer.submitSuspendable { - val queryChannelsRequest = buildQueryChannelsRequest(searchQuery) ?: run { - logger.v { "[observeQueryChannels] rejected (filter not yet initialized)" } - return@submitSuspendable - } - logger.d { "[observeQueryChannels] request: $queryChannelsRequest" } - queryChannelsState = chatClient.queryChannelsAsState( - request = queryChannelsRequest, + /** + * 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, + ) + } + + /** + * 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. + */ + private fun observeQueryChannelsInternal( + tag: String, + createState: () -> StateFlow?, + ) = runCatching { + queryChannelDebouncer.submitSuspendable { + queryChannelsState = createState() ?: return@submitSuspendable queryChannelsState.filterNotNull().collectLatest { queryChannelsState -> combine( queryChannelsState.channelsStateData, @@ -507,13 +635,13 @@ public class ChannelListViewModel internal constructor( when (state) { ChannelsStateData.NoQueryActive, ChannelsStateData.Loading, - -> channelsState.copy( - isLoading = true, - searchQuery = _searchQuery.value, - ).also { logger.d { "[observeQueryChannels] state: Loading" } } + -> { + logger.d { "[$tag] state: Loading" } + channelsState.copy(isLoading = true, searchQuery = _searchQuery.value) + } ChannelsStateData.OfflineNoResults -> { - logger.v { "[observeQueryChannels] state: OfflineNoResults(channels are empty)" } + logger.v { "[$tag] state: OfflineNoResults(channels are empty)" } channelsState.copy( isLoading = false, channelItems = emptyList(), @@ -522,7 +650,7 @@ public class ChannelListViewModel internal constructor( } 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( @@ -542,8 +670,8 @@ public class ChannelListViewModel internal constructor( } }.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" } } } @@ -551,9 +679,6 @@ public class ChannelListViewModel internal constructor( * Builds a [QueryChannelsRequest] for the current [mode] and [searchQuery]. Returns `null` in Standard * mode when the filter has not yet been resolved (e.g. before [buildDefaultFilter] completes); in that * case the caller should skip the request — the next emission of [filterFlow] will re-trigger. - * - * In Predefined mode with an active channel search, falls back to a Standard request whose filter is - * just [optimizedChannelSearchFilter] (the predefined filter is server-owned and cannot be combined locally). */ private fun buildQueryChannelsRequest(searchQuery: String): QueryChannelsRequest? = when (val mode = mode) { is QueryMode.Standard -> { @@ -583,6 +708,13 @@ public class ChannelListViewModel internal constructor( memberLimit = memberLimit, ) } + // When in Grouped mode, this is reached only when search is active + is QueryMode.Grouped -> QueryChannelsRequest( + filter = optimizedChannelSearchFilter(searchQuery), + limit = channelLimit, + messageLimit = messageLimit, + memberLimit = memberLimit, + ) } /** @@ -626,17 +758,19 @@ public class ChannelListViewModel internal constructor( ) } - private fun optimizedChannelSearchFilter(text: String) = + private fun optimizedChannelSearchFilter(text: String): FilterObject = Filters.and( Filters.autocomplete("name", text), Filters.`in`("members", user.value?.id.orEmpty()), ) - private fun messageSearchChannelFilter() = when (mode) { + private fun messageSearchChannelFilter(): FilterObject? = when (mode) { // Standard mode: Use the initial filters (backwards compatible) is QueryMode.Standard -> filterFlow.value ?: Filters.defaultChannelListFilter(user.value) - // Predefined mode: Use simple membership filter (aligned with other platforms) - is QueryMode.Predefined -> when (val userId = user.value?.id) { + // Predefined and Grouped modes: Use simple membership filter (aligned with other platforms); + is QueryMode.Predefined, + is QueryMode.Grouped, + -> when (val userId = user.value?.id) { null -> null else -> Filters.`in`("members", listOf(userId)) } @@ -685,15 +819,23 @@ public class ChannelListViewModel internal constructor( * Use this if you need to support runtime filter changes, through custom filters UI. The applied * filter overrides the `initialFilters` set through the constructor. * - * Has no effect on view models constructed for a predefined-filter query — the predefined identity - * is fixed at construction. A warning is logged in that case. + * Warning: The filter that's applied will override the `initialFilters` set through the constructor. + * Has no effect on view models constructed for a predefined-filter query (predefined identity is + * fixed at construction) or for a grouped query (the group's filter is server-owned). * * @param newFilters The new filters to be used as a baseline for filtering channels. */ public fun setFilters(newFilters: FilterObject) { - if (mode is QueryMode.Predefined) { - logger.w { "[setFilters] ignored — view model uses predefined filter '${mode.name}'" } - return + when (mode) { + is QueryMode.Predefined -> { + logger.w { "[setFilters] ignored — view model uses predefined filter '${mode.name}'" } + return + } + is QueryMode.Grouped -> { + logger.w { "[setFilters] no-op in Grouped mode (groupKey: ${mode.groupKey})" } + return + } + is QueryMode.Standard -> Unit } this.filterFlow.tryEmit(value = newFilters) } @@ -703,13 +845,20 @@ public class ChannelListViewModel internal constructor( * * Use this if you need to support runtime sort changes, through custom sort UI. * - * Has no effect on view models constructed for a predefined-filter query — the sort is resolved by - * the server. A warning is logged in that case. + * Has no effect on view models constructed for a predefined-filter query (sort is resolved by + * the server) or for a grouped query (the group's sort is fixed). */ public fun setQuerySort(querySort: QuerySorter) { - if (mode is QueryMode.Predefined) { - logger.w { "[setQuerySort] ignored — view model uses predefined filter '${mode.name}'" } - return + when (mode) { + is QueryMode.Predefined -> { + logger.w { "[setQuerySort] ignored — view model uses predefined filter '${mode.name}'" } + return + } + is QueryMode.Grouped -> { + logger.w { "[setQuerySort] no-op in Grouped mode (groupKey: ${mode.groupKey})" } + return + } + is QueryMode.Standard -> Unit } this.querySortFlow.tryEmit(value = querySort) } @@ -735,6 +884,15 @@ public class ChannelListViewModel internal constructor( private suspend fun loadMoreQueryChannels() { logger.d { "[loadMoreQueryChannels] no args" } + + // 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)" } @@ -760,6 +918,7 @@ public class ChannelListViewModel internal constructor( ) } is QueryMode.Predefined -> currentQuery + is QueryMode.Grouped -> currentQuery } if (lastNextQuery == nextQuery) { logger.v { "[loadMoreQueryChannels] rejected (same query)" } @@ -777,6 +936,47 @@ public class ChannelListViewModel internal constructor( 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 + } + val config = state.groupedQueryConfig.value + channelsState = channelsState.copy(isLoadingMore = true) + val result = chatClient.queryGroupedChannelsInternal( + limit = config?.limit, + groups = mapOf( + groupKey to GroupedChannelsGroupQuery( + limit = config?.pageSize, + next = cursor, + ), + ), + watch = config?.watch ?: true, + presence = config?.presence ?: false, + ).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]. @@ -930,19 +1130,6 @@ public class ChannelListViewModel internal constructor( private const val MIN_CHANNEL_SEARCH_QUERY_LENGTH = 3 } - internal sealed interface QueryMode { - data class Standard( - val initialFilter: FilterObject?, - val initialSort: QuerySorter, - ) : QueryMode - - data class Predefined( - val name: String, - val filterValues: Map?, - val sortValues: Map?, - ) : QueryMode - } - private data class SearchMessageState( val query: String = "", val canLoadMore: Boolean = true, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt index 960e3e46a96..c18fe8bc949 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactory.kt @@ -27,20 +27,21 @@ 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.grouped.internal.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 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 chatClient The prepared [ChatClient] instance required for fetching the data. + * @param mode The mode representing the query type used to fetch the channel list. + * @param channelLimit The number of channels teo retrieve per page. + * @param memberLimit The number of members to fetch per channel. When `null`, the server-side default is used. + * @param messageLimit The number of messages to fetch per channel. When `null`, the server-side default is used. * @param chatEventHandlerFactory The instance of [ChatEventHandlerFactory] used to create [ChatEventHandler]. - * @param messageSearchSort Optional sorting for message search results. When `null`, the server-side default is used. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Sorting for message search results. When `null`, the server-side default is used. */ @Suppress("LongParameterList") public class ChannelViewModelFactory internal constructor( @@ -57,9 +58,16 @@ public class ChannelViewModelFactory internal constructor( /** * Builds a factory for a [ChannelListViewModel] that queries channels by an explicit filter and sort. * + * @param chatClient The prepared [ChatClient] instance required for fetching the data. * @param querySort The sorting order for channels. * @param filters The base filters used to filter out channels. When `null`, a default filter scoped - * to messaging channels the current user is a member of is used. + * to "messaging" channels the current user is a member of is used. + * @param channelLimit The number of channels teo retrieve per page. + * @param memberLimit The number of members to fetch per channel. When `null`, the server-side default is used. + * @param messageLimit The number of messages to fetch per channel. 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 Sorting for message search results. When `null`, the server-side default is used. */ @JvmOverloads public constructor( @@ -87,9 +95,16 @@ public class ChannelViewModelFactory internal constructor( * Builds a factory for a [ChannelListViewModel] that queries channels using a predefined filter * resolved by the server. * + * @param chatClient The prepared [ChatClient] instance required for fetching the data. * @param predefinedFilterName The name of the predefined filter registered on the backend. * @param filterValues Optional values interpolated into the predefined filter template. * @param sortValues Optional values interpolated into the predefined sort template. + * @param channelLimit The number of channels teo retrieve per page. + * @param memberLimit The number of members to fetch per channel. When `null`, the server-side default is used. + * @param messageLimit The number of messages to fetch per channel. 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 Sorting for message search results. When `null`, the server-side default is used. */ @JvmOverloads public constructor( @@ -118,6 +133,37 @@ public class ChannelViewModelFactory internal constructor( messageSearchSort = messageSearchSort, ) + /** + * Grouped [ChannelListViewModel] factory. Wires the ViewModel to the state identified by + * [groupKey] without firing a remote call; `queryGroupedChannels` responses populate it. + * + * **IMPORTANT: This is an enterprise feature and is disabled by default. For more info, reach out to our + * Contact & Support.** + + * @param groupKey The name of the channels group. + * @param chatClient The prepared [ChatClient] instance required for fetching the data. + * @param isDraftMessageEnabled If the draft message feature is enabled. + * @param messageSearchSort Optional sorting for message search results. + */ + public constructor( + groupKey: String, + chatClient: ChatClient = ChatClient.instance(), + 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. */ 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 e081b8b0057..6c7a5c730da 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 @@ -24,12 +24,15 @@ import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.state.channels.list.SearchQuery +import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.AndFilterObject import io.getstream.chat.android.models.AutocompleteFilterObject import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.InFilterObject import io.getstream.chat.android.models.InitializationState import io.getstream.chat.android.models.Message @@ -45,6 +48,7 @@ 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.global.GlobalState import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData +import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.test.TestCoroutineExtension import io.getstream.chat.android.test.asCall @@ -73,6 +77,7 @@ import org.mockito.kotlin.whenever import java.util.Date @Suppress("LargeClass") +@OptIn(InternalStreamChatApi::class) @ExperimentalCoroutinesApi @ExtendWith(TestCoroutineExtension::class) internal class ChannelListViewModelTest { @@ -317,39 +322,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 { @@ -530,6 +502,85 @@ 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()) + } + + @Test + fun `Given grouped ViewModel with captured config When loading more Should reuse limit pageSize watch and presence`() = + runTest { + val chatClient: ChatClient = mock() + val capturedConfig = GroupedQueryConfig( + limit = 20, + pageSize = 5, + watch = true, + presence = false, + ) + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + channels = listOf(channel1), + loading = false, + nextCursor = "cursor-1", + groupedQueryConfig = capturedConfig, + ) + .givenChannelMutes() + .givenGroupedChannelsQuery() + .get(this, groupKey = "team-a") + + viewModel.loadMore() + advanceUntilIdle() + + verify(chatClient).queryGroupedChannelsInternal( + limit = 20, + groups = mapOf( + "team-a" to GroupedChannelsGroupQuery(limit = 5, next = "cursor-1"), + ), + watch = true, + presence = false, + ) + } + + @Test + fun `Given grouped ViewModel with no captured config When loading more Should fall back to method defaults`() = + runTest { + val chatClient: ChatClient = mock() + val viewModel = Fixture(chatClient) + .givenCurrentUser() + .givenChannelsState( + channelsStateData = ChannelsStateData.Result(listOf(channel1)), + channels = listOf(channel1), + loading = false, + nextCursor = "cursor-2", + groupedQueryConfig = null, + ) + .givenChannelMutes() + .givenGroupedChannelsQuery() + .get(this, groupKey = "team-a") + + viewModel.loadMore() + advanceUntilIdle() + + verify(chatClient).queryGroupedChannelsInternal( + limit = null, + groups = mapOf( + "team-a" to GroupedChannelsGroupQuery(limit = null, next = "cursor-2"), + ), + watch = true, + presence = false, + ) + } + @Test fun `Given predefined filter When initializing Should issue predefined-shaped query`() = runTest { val chatClient: ChatClient = mock() @@ -783,8 +834,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 @@ -815,6 +871,19 @@ internal class ChannelListViewModelTest { whenever(chatClient.queryChannels(any())) doReturn channels.asCall() } + fun givenGroupedChannelsQuery( + result: GroupedChannels = GroupedChannels(groups = emptyMap()), + ) = apply { + whenever( + chatClient.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) doReturn result.asCall() + } + fun givenDeleteChannel() = apply { whenever(channelClient.delete()) doReturn Channel().asCall() } @@ -858,6 +927,8 @@ internal class ChannelListViewModelTest { loadingMore: Boolean = false, endOfChannels: Boolean = false, nextPageRequest: QueryChannelsRequest? = null, + nextCursor: String? = null, + groupedQueryConfig: GroupedQueryConfig? = null, ) = apply { val queryChannelsState: QueryChannelsState = mock { whenever(it.channelsStateData) doReturn MutableStateFlow(channelsStateData) @@ -866,16 +937,31 @@ 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(it.groupedQueryConfig) doReturn MutableStateFlow(groupedQueryConfig) } + whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState + whenever(stateRegistry.queryChannels(any())) doReturn queryChannelsState + } + + fun givenChannelsState(queryChannelsState: QueryChannelsState) = apply { + whenever(stateRegistry.queryChannels(any(), any())) doReturn queryChannelsState whenever(stateRegistry.queryChannels(any())) doReturn queryChannelsState } - fun get(testScope: TestScope): ChannelListViewModel { - val name = predefinedFilterName - val channelListViewModel = if (name != null) { - ChannelListViewModel( + fun get(testScope: TestScope, groupKey: String? = null): ChannelListViewModel { + val predefinedName = predefinedFilterName + val channelListViewModel = when { + groupKey != null -> ChannelListViewModel( + chatClient = chatClient, + groupKey = groupKey, + isDraftMessageEnabled = false, + messageSearchSort = messageSearchSort, + globalState = MutableStateFlow(globalState), + ) + predefinedName != null -> ChannelListViewModel( chatClient = chatClient, - predefinedFilterName = name, + predefinedFilterName = predefinedName, filterValues = predefinedFilterValues, sortValues = predefinedSortValues, isDraftMessageEnabled = false, @@ -883,8 +969,7 @@ internal class ChannelListViewModelTest { messageSearchSort = messageSearchSort, globalState = MutableStateFlow(globalState), ) - } else { - ChannelListViewModel( + else -> ChannelListViewModel( chatClient = chatClient, initialSort = initialSort, initialFilters = initialFilters, diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactoryTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactoryTest.kt index 14af86bdb51..8ce0ef809a4 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactoryTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channels/ChannelViewModelFactoryTest.kt @@ -28,8 +28,26 @@ import org.mockito.kotlin.mock internal class ChannelViewModelFactoryTest { @Test - fun `create should return correct instance`() { - val sut = Fixture().get() + fun `Standard constructor produces a factory that creates a ChannelListViewModel`() { + val sut = Fixture().getStandard() + + val viewModel = sut.create(ChannelListViewModel::class.java) + + assertInstanceOf(viewModel) + } + + @Test + fun `Predefined constructor produces a factory that creates a ChannelListViewModel`() { + val sut = Fixture().getPredefined(predefinedFilterName = "my-filter") + + val viewModel = sut.create(ChannelListViewModel::class.java) + + assertInstanceOf(viewModel) + } + + @Test + fun `Grouped constructor produces a factory that creates a ChannelListViewModel`() { + val sut = Fixture().getGrouped(groupKey = "direct") val viewModel = sut.create(ChannelListViewModel::class.java) @@ -38,7 +56,7 @@ internal class ChannelViewModelFactoryTest { @Test fun `create should throw IllegalArgumentException for unsupported ViewModel class`() { - val sut = Fixture().get() + val sut = Fixture().getStandard() val exception = assertThrows(IllegalArgumentException::class.java) { sut.create(ViewModel::class.java) @@ -55,8 +73,22 @@ internal class ChannelViewModelFactoryTest { on { clientState } doReturn mock() } - fun get(): ChannelViewModelFactory { + fun getStandard(): ChannelViewModelFactory { + return ChannelViewModelFactory( + chatClient = mockChatClient, + ) + } + + fun getPredefined(predefinedFilterName: String): ChannelViewModelFactory { + return ChannelViewModelFactory( + chatClient = mockChatClient, + predefinedFilterName = predefinedFilterName, + ) + } + + fun getGrouped(groupKey: String): ChannelViewModelFactory { return ChannelViewModelFactory( + groupKey = groupKey, chatClient = mockChatClient, ) } 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 1d89b7eca61..90af4a4a5a0 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 = 99, + version = 100, 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 a056d816e7f..93da49c6144 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 @@ -53,6 +53,11 @@ internal class DatabaseQueryChannelsRepository( return queryChannelsDao.select(generateId(identifier))?.let(::toModel) } + override suspend fun selectBy(groupKey: String): QueryChannelsSpec? { + val identifier = QueryChannelsIdentifier.Grouped(groupKey) + return queryChannelsDao.select(generateId(identifier))?.let(::toModel) + } + override suspend fun clear() { queryChannelsDao.deleteAll() } @@ -60,33 +65,36 @@ internal class DatabaseQueryChannelsRepository( private companion object { // Standard: hash of (filter, sort). Predefined: name + value-map hashes, since the // resolved filter/sort are unknown until the server replies and we need stable identity - // across runs. + // across runs. Grouped: stable groupKey returned by the server. private fun generateId(identifier: QueryChannelsIdentifier): String = when (identifier) { is QueryChannelsIdentifier.Standard -> "${identifier.filter.hashCode()}-${identifier.sort.toDto().hashCode()}" is QueryChannelsIdentifier.Predefined -> "pd:${identifier.name}:${identifier.filterValues.hashCode()}:${identifier.sortValues.hashCode()}" + is QueryChannelsIdentifier.Grouped -> + "grp:${identifier.groupKey}" } - private fun toEntity(queryChannelsSpec: QueryChannelsSpec): QueryChannelsEntity = + private fun toEntity(spec: QueryChannelsSpec): QueryChannelsEntity = QueryChannelsEntity( - id = generateId(queryChannelsSpec.identifier), - filter = queryChannelsSpec.filter, - querySort = queryChannelsSpec.querySort, - cids = queryChannelsSpec.cids.toList(), - predefinedFilterName = queryChannelsSpec.predefinedFilterName, - predefinedFilterValues = queryChannelsSpec.predefinedFilterValues, - predefinedSortValues = queryChannelsSpec.predefinedSortValues, + id = generateId(spec.identifier), + filter = spec.filter, + querySort = spec.querySort, + cids = spec.cids.toList(), + groupKey = spec.groupKey, + predefinedFilterName = spec.predefinedFilterName, + predefinedFilterValues = spec.predefinedFilterValues, + predefinedSortValues = spec.predefinedSortValues, ) - private fun toModel(queryChannelsEntity: QueryChannelsEntity): QueryChannelsSpec = + private fun toModel(entity: QueryChannelsEntity): QueryChannelsSpec = QueryChannelsSpec( - filter = queryChannelsEntity.filter, - querySort = queryChannelsEntity.querySort, - cids = queryChannelsEntity.cids.toSet(), - predefinedFilterName = queryChannelsEntity.predefinedFilterName, - predefinedFilterValues = queryChannelsEntity.predefinedFilterValues, - predefinedSortValues = queryChannelsEntity.predefinedSortValues, - ) + filter = entity.filter, + querySort = entity.querySort, + groupKey = entity.groupKey, + predefinedFilterName = entity.predefinedFilterName, + predefinedFilterValues = entity.predefinedFilterValues, + predefinedSortValues = entity.predefinedSortValues, + ).also { it.cids = entity.cids.toSet() } } } 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 785c0bd04b1..381a0f82454 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 @@ -40,6 +40,8 @@ internal data class QueryChannelsEntity( /** Resolved sort. For predefined queries this is the latest server-resolved value. */ val querySort: QuerySorter, val cids: List, + /** Set only for grouped queries; null otherwise. Identity is the stable server-returned groupKey. */ + val groupKey: String? = null, /** * Set only for predefined-filter queries; null for standard ones. Together with the value maps * below, the predefined name forms the row's stable identity (the resolved filter/sort can diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt index ff6e7b61a74..c8cf57b9ad8 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt @@ -250,6 +250,7 @@ internal fun randomQueryChannelsEntity( filter: FilterObject = NeutralFilterObject, querySort: QuerySorter = QuerySortByField(), cids: List = emptyList(), + groupKey: String? = null, predefinedFilterName: String? = null, predefinedFilterValues: Map? = null, predefinedSortValues: Map? = null, @@ -258,6 +259,7 @@ internal fun randomQueryChannelsEntity( filter = filter, querySort = querySort, cids = cids, + groupKey = groupKey, predefinedFilterName = predefinedFilterName, predefinedFilterValues = predefinedFilterValues, predefinedSortValues = predefinedSortValues, diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt index ca6e3eaeb0f..80499b51f0d 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/QueryChannelsImplRepositoryTest.kt @@ -131,4 +131,40 @@ internal class QueryChannelsImplRepositoryTest { assertEquals(mapOf("a" to 1), spec.predefinedFilterValues) assertEquals(mapOf("b" to 2), spec.predefinedSortValues) } + + @Test + fun `selectBy groupKey looks up the row under the grouped DB id`() = runTest { + whenever(dao.select(any())) doReturn randomQueryChannelsEntity( + id = "grp:direct", + cids = listOf("cid1"), + groupKey = "direct", + ) + + val spec = sut.selectBy(groupKey = "direct") + + spec.shouldNotBeNull() + assertEquals("direct", spec.groupKey) + assertEquals(setOf("cid1"), spec.cids) + verify(dao).select("grp:direct") + } + + @Test + fun `selectBy groupKey returns null when no row exists`() = runTest { + whenever(dao.select(any())) doReturn null + + val spec = sut.selectBy(groupKey = "direct") + + spec.shouldBeNull() + } + + @Test + fun `Two Grouped identifiers with different groupKeys produce different DB ids`() = runTest { + sut.selectBy(groupKey = "direct") + sut.selectBy(groupKey = "support") + + val captor = argumentCaptor() + verify(dao, times(2)).select(captor.capture()) + assertEquals("grp:direct", captor.allValues[0]) + assertEquals("grp:support", captor.allValues[1]) + } } 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 be936f16aa9..e49e913e9c2 100644 --- a/stream-chat-android-state/api/stream-chat-android-state.api +++ b/stream-chat-android-state/api/stream-chat-android-state.api @@ -209,6 +209,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; @@ -245,6 +246,23 @@ public final class io/getstream/chat/android/state/plugin/state/querychannels/Ch public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig { + public fun (Ljava/lang/Integer;Ljava/lang/Integer;ZZ)V + public final fun component1 ()Ljava/lang/Integer; + public final fun component2 ()Ljava/lang/Integer; + public final fun component3 ()Z + public final fun component4 ()Z + public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;ZZ)Lio/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig; + public static synthetic fun copy$default (Lio/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig;Ljava/lang/Integer;Ljava/lang/Integer;ZZILjava/lang/Object;)Lio/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig; + public fun equals (Ljava/lang/Object;)Z + public final fun getLimit ()Ljava/lang/Integer; + public final fun getPageSize ()Ljava/lang/Integer; + public final fun getPresence ()Z + public final fun getWatch ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class io/getstream/chat/android/state/plugin/state/querychannels/QueryChannelsState { public abstract fun getChannels ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getChannelsStateData ()Lkotlinx/coroutines/flow/StateFlow; @@ -252,8 +270,10 @@ public abstract interface class io/getstream/chat/android/state/plugin/state/que public abstract fun getCurrentRequest ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getEndOfChannels ()Lkotlinx/coroutines/flow/StateFlow; public abstract fun getFilter ()Lio/getstream/chat/android/models/FilterObject; + public abstract fun getGroupedQueryConfig ()Lkotlinx/coroutines/flow/StateFlow; 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/grouped/internal/ChannelGroupExtensions.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/ChannelGroupExtensions.kt new file mode 100644 index 00000000000..5ebac7bfaf2 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/ChannelGroupExtensions.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.event.handler.grouped.internal + +import io.getstream.chat.android.models.Channel + +/** Conventional `extraData` key under which a channel's group identifier is stored. */ +internal const val DEFAULT_GROUP_FIELD_NAME: String = "group" + +/** The channel's group identifier as stored in `extraData[DEFAULT_GROUP_FIELD_NAME]`. */ +internal val Channel.group: String? + get() = extraData[DEFAULT_GROUP_FIELD_NAME] as? String diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/ChannelGroupResolver.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/ChannelGroupResolver.kt new file mode 100644 index 00000000000..355abb91812 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/ChannelGroupResolver.kt @@ -0,0 +1,37 @@ +/* + * 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.grouped.internal + +import io.getstream.chat.android.models.Channel + +/** + * Resolves the set of group keys a [Channel] belongs to for the purposes of grouped channel + * lists driven by `queryGroupedChannels`. + * + * Used by [GroupAwareChatEventHandler] to decide whether an incoming channel-bearing event should be handled + * by adding the relevant channel to a new group, or removing the channel from its current group. + * Classification is performed against the channel's own `extraData`. + */ +internal fun interface ChannelGroupResolver { + + /** + * @param channel The channel whose group membership is being resolved. + * @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(channel: Channel): Set +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/DefaultChannelGroupResolver.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/DefaultChannelGroupResolver.kt new file mode 100644 index 00000000000..50a13fdfbdc --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/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.grouped.internal + +import io.getstream.chat.android.models.Channel + +/** + * Default [ChannelGroupResolver] backed by `channel.extraData`. + * + * Reads an explicit group key from `channel.extraData[groupFieldName]` (defaults to `"group"`) + * and always includes the [allGroupKey] sentinel (defaults to `"all"`) so that a designated + * "all channels" grouped query always sees every channel. + * + * @param groupFieldName The key in `channel.extraData` carrying the explicit group identifier. + * @param allGroupKey The sentinel group key representing "every channel". Pass `null` to disable + * the implicit sentinel. + */ +internal class DefaultChannelGroupResolver( + private val groupFieldName: String = DEFAULT_GROUP_FIELD_NAME, + private val allGroupKey: String? = DEFAULT_ALL_GROUP_KEY, +) : ChannelGroupResolver { + + override fun resolve(channel: Channel): Set = buildSet { + (channel.extraData[groupFieldName] as? String)?.let(::add) + allGroupKey?.let(::add) + } + + companion object { + 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/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt new file mode 100644 index 00000000000..4f97cd7fc77 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.event.handler.grouped.internal + +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.ChatEvent +import io.getstream.chat.android.client.events.CidEvent +import io.getstream.chat.android.client.events.HasChannel +import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent +import io.getstream.chat.android.client.events.NotificationMessageNewEvent +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.FilterObject +import io.getstream.chat.android.state.event.handler.chat.DefaultChatEventHandler +import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import kotlinx.coroutines.flow.StateFlow + +/** + * [DefaultChatEventHandler] that routes channels in and out of a grouped channel list based on the + * channel's resolved group(s). Paired with `QueryChannelsIdentifier.Grouped(groupKey)` — one + * handler instance per grouped query. + * + * For `cid`-only events the handler delegates to [DefaultChatEventHandler] and filters any + * resulting `Add` through the resolver. Removal events are inherited unchanged. + * + * Overrides [handleChatEvent] (not [handleChannelEvent]) so the query layer's `cachedChannel` — a + * per-channel snapshot that has already absorbed preceding `member.removed` events in the same + * batch — is available for the membership check. + */ +internal class GroupAwareChatEventHandler( + private val groupKey: String, + private val resolver: ChannelGroupResolver, + channels: StateFlow?>, + clientState: ClientState, +) : DefaultChatEventHandler(channels, clientState) { + + override fun handleChatEvent( + event: ChatEvent, + filter: FilterObject, + cachedChannel: Channel?, + ): EventHandlingResult { + return when (event) { + is ChannelUpdatedEvent, + is ChannelUpdatedByUserEvent, + -> routeByGroup((event as HasChannel).channel, cachedChannel) + + is NotificationAddedToChannelEvent, + is NotificationMessageNewEvent, + is ChannelVisibleEvent, + -> if (channelBelongsHere(event.channel)) { + EventHandlingResult.WatchAndAdd(event.cid) + } else { + EventHandlingResult.Skip + } + + else -> super.handleChatEvent(event, filter, cachedChannel) + } + } + + override fun handleCidEvent( + event: CidEvent, + filter: FilterObject, + cachedChannel: Channel?, + ): EventHandlingResult { + val defaultResult = super.handleCidEvent(event, filter, cachedChannel) + return filterResultByGroup(defaultResult, cachedChannel) + } + + /** + * The membership guard prevents a `ChannelUpdatedEvent` from (re-)adding a channel the user + * has already left — relies on [cachedChannel] (in-memory state after `member.removed` is + * applied) rather than `event.channel.membership`, which is not guaranteed on `channel.updated`. + */ + private fun routeByGroup(channel: Channel, cachedChannel: Channel?): EventHandlingResult { + val belongsHere = channelBelongsHere(channel) && isCurrentUserMember(cachedChannel) + val isInList = channels.value?.containsKey(channel.cid) == true + return when { + belongsHere && !isInList -> EventHandlingResult.Add(channel) + !belongsHere && isInList -> EventHandlingResult.Remove(channel.cid) + else -> EventHandlingResult.Skip + } + } + + private fun channelBelongsHere(channel: Channel): Boolean = + resolver.resolve(channel).contains(groupKey) + + /** + * Reads `cachedChannel.membership`: the SDK maintains this field via member events and does + * not overwrite it from `channel.updated`, so `membership == null` is the authoritative + * "not a member" signal. + * + * Returns `false` whenever membership can't be positively confirmed — [routeByGroup] then + * resolves that to `Remove` (channel currently in list) or `Skip` (not in list). + */ + private fun isCurrentUserMember(cachedChannel: Channel?): Boolean { + val currentUserId = clientState.user.value?.id ?: return false + val membership = cachedChannel?.membership ?: return false + return membership.getUserId() == currentUserId + } + + /** + * Downgrades an `Add`/`WatchAndAdd` from the default handler to `Skip` if the resolver says + * the channel does not belong in this group. `Remove`/`Skip` pass through unchanged. + */ + private fun filterResultByGroup( + result: EventHandlingResult, + cachedChannel: Channel?, + ): EventHandlingResult = when (result) { + is EventHandlingResult.Add -> + if (channelBelongsHere(result.channel)) result else EventHandlingResult.Skip + + is EventHandlingResult.WatchAndAdd -> + // No channel data on the event; use cachedChannel if available. If we have nothing + // to resolve against, trust the default and rely on the subsequent channel.updated + // (which carries full channel data) to clean up. + if (cachedChannel != null && !channelBelongsHere(cachedChannel)) { + EventHandlingResult.Skip + } else { + result + } + + else -> result + } +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt new file mode 100644 index 00000000000..b594fc59371 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt @@ -0,0 +1,63 @@ +/* + * 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.grouped.internal + +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.ChatEventHandler +import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +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/grouped/internal/GroupedUnreadChannelsUpdater.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt new file mode 100644 index 00000000000..a2456fec1ed --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt @@ -0,0 +1,177 @@ +/* + * 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.grouped.internal + +import io.getstream.chat.android.client.events.ChannelUpdatedByUserEvent +import io.getstream.chat.android.client.events.ChannelUpdatedEvent +import io.getstream.chat.android.client.events.HasGroupedUnreadChannels +import io.getstream.chat.android.client.events.MarkAllReadEvent +import io.getstream.chat.android.client.events.NewMessageEvent +import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent +import io.getstream.chat.android.client.events.NotificationMarkReadEvent +import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent +import io.getstream.chat.android.client.events.NotificationMessageNewEvent +import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.extensions.currentUserUnreadCount +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.UserId +import io.getstream.chat.android.state.plugin.state.StateRegistry + +/** + * Single contract for evolving the per-group unread-channel counts map exposed via + * `GlobalState.groupedUnreadChannels`. + * + * `channel.updated` / `channel.updated_by_user` deltas are computed against the pre-batch cached + * channel, which is not refreshed until `updateChannelsState` runs after the global-state pass. + * To stay correct against same-cid events earlier in the same batch (mark-read, deletion, removal, + * etc.) the updater keeps batch-scoped overrides keyed on `BatchEvent.id` and auto-cleared when a + * new batch id arrives. + */ +internal class GroupedUnreadChannelsUpdater( + private val stateRegistry: StateRegistry, + private val currentUserId: UserId, +) { + + private var memoBatchId: Int? = null + private val processedCids = mutableSetOf() + private val removedCids = mutableSetOf() + private val hadUnreadOverride = mutableMapOf() + private var markAllReadApplied: Boolean = false + + /** + * Result of a `queryGroupedChannels` call. Per-group counts are MERGED into the current map + * so groups not present in the response retain their existing counts. + */ + fun calculateUpdatedCounts( + current: Map, + result: GroupedChannels, + ): Map = current + result.groups.mapValues { (_, g) -> g.unreadChannels } + + /** + * Backend-pushed authoritative map (from any [HasGroupedUnreadChannels] event). The event's + * map REPLACES the current one (or returns it unchanged if the event carries no map). The + * event subtype is then inspected to flip the per-batch overrides for its cid so subsequent + * `channel.updated` deltas in the same batch see the post-event state. + */ + fun calculateUpdatedCounts( + current: Map, + batchId: Int, + event: HasGroupedUnreadChannels, + ): Map { + rotateBatchIfNeeded(batchId) + val next = event.groupedUnreadChannels ?: current + if (next !== current) { + // The map was replaced, so the per-batch dedup no longer applies to later events. + processedCids.clear() + } + recordOverridesFrom(event) + return next + } + + /** + * `channel.updated` delta. If the channel changed group and the current user still has + * unread on it (per the in-batch overrides + cached state), decrement the old group's count + * and increment the new group's count. + */ + fun calculateUpdatedCounts( + current: Map, + batchId: Int, + event: ChannelUpdatedEvent, + ): Map = applyDelta(current, batchId, event.cid, event.channel) + + /** + * `channel.updated_by_user` delta. Same semantics as the [ChannelUpdatedEvent] overload. + */ + fun calculateUpdatedCounts( + current: Map, + batchId: Int, + event: ChannelUpdatedByUserEvent, + ): Map = applyDelta(current, batchId, event.cid, event.channel) + + /** + * Records that channel [cid] has been removed within [batchId] — either deleted, or the + * current user is no longer a member. Later in-batch deltas for that cid are skipped. + */ + fun notifyChannelRemoved(batchId: Int, cid: String) { + rotateBatchIfNeeded(batchId) + removedCids += cid + } + + private fun recordOverridesFrom(event: HasGroupedUnreadChannels) { + when (event) { + is NotificationMarkReadEvent -> hadUnreadOverride[event.cid] = false + is NotificationMarkUnreadEvent -> hadUnreadOverride[event.cid] = true + is NewMessageEvent -> + if (event.user.id != currentUserId) hadUnreadOverride[event.cid] = true + is NotificationMessageNewEvent -> hadUnreadOverride[event.cid] = true + is NotificationChannelDeletedEvent -> removedCids += event.cid + is NotificationChannelTruncatedEvent -> hadUnreadOverride[event.cid] = false + is MarkAllReadEvent -> { + markAllReadApplied = true + hadUnreadOverride.clear() + } + } + } + + private fun applyDelta( + current: Map, + batchId: Int, + cid: String, + newChannel: Channel, + ): Map { + rotateBatchIfNeeded(batchId) + if (cid in removedCids || cid in processedCids) return current + val oldChannel = cachedChannel(cid) ?: return current + val oldGroup = oldChannel.group + val newGroup = newChannel.group + if (oldGroup == newGroup) return current + if (!hadUnreadFor(cid, oldChannel)) return current + val next = current.toMutableMap().apply { + oldGroup?.let { this[it] = ((this[it] ?: 0) - 1).coerceAtLeast(0) } + newGroup?.let { this[it] = (this[it] ?: 0) + 1 } + } + if (next == current) return current + processedCids += cid + return next + } + + private fun hadUnreadFor(cid: String, oldChannel: Channel): Boolean = when { + cid in hadUnreadOverride -> hadUnreadOverride.getValue(cid) + markAllReadApplied -> false + else -> oldChannel.currentUserUnreadCount(currentUserId) > 0 + } + + private fun rotateBatchIfNeeded(batchId: Int) { + if (memoBatchId == batchId) return + memoBatchId = batchId + processedCids.clear() + removedCids.clear() + hadUnreadOverride.clear() + markAllReadApplied = false + } + + private fun cachedChannel(cid: String): Channel? { + val (channelType, channelId) = cid.cidToTypeAndId() + return if (stateRegistry.isActiveChannel(channelType, channelId)) { + stateRegistry.mutableChannel(channelType, channelId).toChannel() + } else { + null + } + } +} 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 9f798302d72..f3dbe6d4d3e 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 @@ -110,6 +111,7 @@ import io.getstream.chat.android.models.User import io.getstream.chat.android.models.UserId import io.getstream.chat.android.models.mergeChannelFromEvent import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.event.handler.grouped.internal.GroupedUnreadChannelsUpdater import io.getstream.chat.android.state.event.handler.internal.batch.BatchEvent import io.getstream.chat.android.state.event.handler.internal.batch.SocketEventCollector import io.getstream.chat.android.state.event.handler.internal.utils.realType @@ -162,6 +164,10 @@ internal class EventHandlerSequential( private val syncedEvents: Flow>, private val bufferConfig: MessageBufferConfig, scope: CoroutineScope, + private val groupedUnreadChannelsUpdater: GroupedUnreadChannelsUpdater = GroupedUnreadChannelsUpdater( + stateRegistry = stateRegistry, + currentUserId = currentUserId, + ), ) : EventHandler { private val logger by taggedLogger(TAG) @@ -304,7 +310,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 @@ -367,6 +380,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 @@ -414,22 +428,38 @@ internal class EventHandlerSequential( } } - batchEvent - .takeUnless { it.isFromHistorySync } - ?.sortedEvents - ?.forEach { event: ChatEvent -> - (event as? DraftMessageUpdatedEvent)?.let { mutableGlobalState.updateDraftMessage(it.draftMessage) } - (event as? DraftMessageDeletedEvent)?.let { mutableGlobalState.removeDraftMessage(it.draftMessage) } - (event as? HasUnreadCounts)?.let { modifyValuesFromEvent(it) } - (event as? HasOwnUser)?.let { modifyValuesFromUser(it.me) } - (event as? HasUnreadThreadCounts)?.let { modifyUnreadThreadsCount(it) } - (event as? UserUpdatedEvent) - ?.takeIf { it.user.id == currentUserId } - ?.let { modifyValuesFromUser(me?.mergePartially(it.user) ?: it.user) } - (event as? NewMessageEvent)?.message?.sharedLocation?.let(mutableGlobalState::addLiveLocation) - (event as? MessageUpdatedEvent)?.message?.sharedLocation?.let(mutableGlobalState::addLiveLocation) - (event as? HasChannel)?.channel?.activeLiveLocations?.let(mutableGlobalState::addLiveLocations) + val sortedEvents = batchEvent.takeUnless { it.isFromHistorySync }?.sortedEvents.orEmpty() + + sortedEvents.forEach { event: ChatEvent -> + (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)?.let { e -> + groupedUnreadChannels = groupedUnreadChannelsUpdater + .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) + } + (event as? NotificationRemovedFromChannelEvent) + ?.takeIf { it.member.getUserId() == currentUserId } + ?.let { groupedUnreadChannelsUpdater.notifyChannelRemoved(batchEvent.id, it.cid) } + (event as? ChannelDeletedEvent) + ?.let { groupedUnreadChannelsUpdater.notifyChannelRemoved(batchEvent.id, it.cid) } + (event as? ChannelUpdatedEvent)?.let { e -> + groupedUnreadChannels = groupedUnreadChannelsUpdater + .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) } + (event as? ChannelUpdatedByUserEvent)?.let { e -> + groupedUnreadChannels = groupedUnreadChannelsUpdater + .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) + } + (event as? HasOwnUser)?.let { modifyValuesFromUser(it.me) } + (event as? HasUnreadThreadCounts)?.let { modifyUnreadThreadsCount(it) } + (event as? UserUpdatedEvent) + ?.takeIf { it.user.id == currentUserId } + ?.let { modifyValuesFromUser(me?.mergePartially(it.user) ?: it.user) } + (event as? NewMessageEvent)?.message?.sharedLocation?.let(mutableGlobalState::addLiveLocation) + (event as? MessageUpdatedEvent)?.message?.sharedLocation?.let(mutableGlobalState::addLiveLocation) + (event as? HasChannel)?.channel?.activeLiveLocations?.let(mutableGlobalState::addLiveLocations) + } me?.let { mutableGlobalState.setBanned(it.isBanned) @@ -438,6 +468,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..a16577398b7 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.grouped.internal.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,7 @@ import io.getstream.chat.android.state.plugin.internal.StatePlugin import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.channel.thread.ThreadState import io.getstream.chat.android.state.plugin.state.global.GlobalState +import io.getstream.chat.android.state.plugin.state.internal.WatchedChannelStateFlow import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querythreads.QueryThreadsState import io.getstream.log.StreamLog @@ -65,10 +68,13 @@ 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.lang.ref.WeakReference 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.groupKey, 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,20 @@ public fun ChatClient.watchChannelAsState( coroutineScope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), ): StateFlow { StreamLog.i(TAG) { "[watchChannelAsState] cid: $cid, messageLimit: $messageLimit" } - return getStateOrNull(coroutineScope) { + val flow = getStateOrNull(coroutineScope) { requestsAsState(coroutineScope).watchChannel(cid, messageLimit, stateConfig.userPresence) } + val watchedFlow = WatchedChannelStateFlow(flow, cid) + // Wrap in a WeakReference so the init-waiting coroutine doesn't pin the flow if the caller + // has already dropped it before initialization completes. + val watchedFlowRef = WeakReference(watchedFlow) + coroutineScope.launch { + runCatching { + clientState.initializationState.first { it == InitializationState.COMPLETE } + watchedFlowRef.get()?.let(state::trackWatchedChannel) + } + } + return watchedFlow } /** 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/factory/StreamStatePluginFactory.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/factory/StreamStatePluginFactory.kt index 125695192cc..6fa607a5ee6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/factory/StreamStatePluginFactory.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/factory/StreamStatePluginFactory.kt @@ -27,6 +27,7 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.User import io.getstream.chat.android.state.errorhandler.StateErrorHandlerFactory +import io.getstream.chat.android.state.event.handler.grouped.internal.GroupedUnreadChannelsUpdater import io.getstream.chat.android.state.event.handler.internal.EventHandler import io.getstream.chat.android.state.event.handler.internal.EventHandlerSequential import io.getstream.chat.android.state.plugin.config.MessageBufferConfig @@ -125,6 +126,11 @@ public class StreamStatePluginFactory( chatClient.logicRegistry = logic + val groupedUnreadChannelsUpdater = GroupedUnreadChannelsUpdater( + stateRegistry = stateRegistry, + currentUserId = user.id, + ) + val syncManager = SyncManager( currentUserId = user.id, scope = scope, @@ -150,6 +156,7 @@ public class StreamStatePluginFactory( clientState = clientState, mutableGlobalState = mutableGlobalState, repos = repositoryFacade, + groupedUnreadChannelsUpdater = groupedUnreadChannelsUpdater, syncedEvents = syncManager.syncedEvents, sideEffect = syncManager::awaitSyncing, bufferConfig = config.messageLimitConfig.messageBufferConfig, @@ -177,6 +184,7 @@ public class StreamStatePluginFactory( syncManager = syncManager, eventHandler = eventHandler, mutableGlobalState = mutableGlobalState, + groupedUnreadChannelsUpdater = groupedUnreadChannelsUpdater, queryingChannelsFree = isQueryingFree, statePluginConfig = config, ) @@ -192,6 +200,7 @@ public class StreamStatePluginFactory( clientState: ClientState, mutableGlobalState: MutableGlobalState, repos: RepositoryFacade, + groupedUnreadChannelsUpdater: GroupedUnreadChannelsUpdater, sideEffect: suspend () -> Unit, syncedEvents: Flow>, bufferConfig: MessageBufferConfig, @@ -205,6 +214,7 @@ public class StreamStatePluginFactory( clientState = clientState, mutableGlobalState = mutableGlobalState, repos = repos, + groupedUnreadChannelsUpdater = groupedUnreadChannelsUpdater, syncedEvents = syncedEvents, sideEffect = sideEffect, bufferConfig = bufferConfig, 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..9aaf98eb440 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 @@ -49,6 +50,7 @@ import io.getstream.chat.android.client.plugin.listeners.UnblockUserListener import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.User +import io.getstream.chat.android.state.event.handler.grouped.internal.GroupedUnreadChannelsUpdater import io.getstream.chat.android.state.event.handler.internal.EventHandler import io.getstream.chat.android.state.plugin.config.StatePluginConfig import io.getstream.chat.android.state.plugin.listener.internal.BlockUserListenerState @@ -67,6 +69,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 @@ -109,11 +112,17 @@ public class StatePlugin internal constructor( private val syncManager: SyncManager, private val eventHandler: EventHandler, private val mutableGlobalState: MutableGlobalState, + private val groupedUnreadChannelsUpdater: GroupedUnreadChannelsUpdater, private val queryingChannelsFree: MutableStateFlow, private val statePluginConfig: StatePluginConfig, ) : Plugin, QueryMembersListener by QueryMembersListenerState(logic), QueryChannelsListener by QueryChannelsListenerState(logic, queryingChannelsFree), + QueryGroupedChannelsListener by QueryGroupedChannelsListenerState( + logic = logic, + globalState = mutableGlobalState, + groupedUnreadChannelsUpdater = groupedUnreadChannelsUpdater, + ), 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 dc9cdec4472..160dfe03212 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 @@ -19,9 +19,7 @@ package io.getstream.chat.android.state.plugin.listener.internal import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryChannelsResult import io.getstream.chat.android.client.plugin.listeners.QueryChannelsListener -import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest -import io.getstream.chat.android.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 logic.queryChannels(request).run { setCurrentRequest(request) - queryOffline(request.toPagination()) + queryOffline(request.toOfflinePaginationRequest()) } } @@ -72,16 +70,4 @@ internal class QueryChannelsListenerState( queryChannelsLogic.onQueryChannelsResult(channels, 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..131d7b93dbe --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerState.kt @@ -0,0 +1,99 @@ +/* + * 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.event.handler.grouped.internal.GroupedUnreadChannelsUpdater +import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig +import io.getstream.result.Result + +internal class QueryGroupedChannelsListenerState( + private val logic: LogicRegistry, + private val globalState: MutableGlobalState, + private val groupedUnreadChannelsUpdater: GroupedUnreadChannelsUpdater, +) : QueryGroupedChannelsListener { + + override suspend fun onQueryGroupedChannelsRequest( + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ) { + // Capture config for every explicitly named group BEFORE the network call so that a + // failed request still leaves enough state for SyncManager to retry with the same + // parameters. When `groups == null` the caller is relying on the server's default group + // set — we don't know the keys until the response, so we defer to the result-side capture + // (which only fires on success, but in that case there is no failure to recover from). + groups?.forEach { (key, groupQuery) -> + logic.queryChannels(QueryChannelsIdentifier.Grouped(key)) + .setGroupedQueryConfig( + GroupedQueryConfig( + limit = limit, + pageSize = groupQuery.limit, + watch = watch, + presence = presence, + ), + ) + } + } + + override suspend fun onQueryGroupedChannelsResult( + result: Result, + limit: Int?, + groups: Map?, + watch: Boolean, + presence: Boolean, + ) { + if (result !is Result.Success) return + + // Only the first page carries initial unread counts. + val isFirstPageRequest = groups.orEmpty().values.none { it.next != null || it.prev != null } + if (isFirstPageRequest) { + val next = groupedUnreadChannelsUpdater.calculateUpdatedCounts( + current = globalState.groupedUnreadChannels.value, + result = result.value, + ) + globalState.setGroupedUnreadChannels(next) + } + + // Route each returned group's channels into the per-group state. The captured config lets + // both ChannelListViewModel.loadMoreGroupedChannels and SyncManager.updateGroupedQueryChannels + // reuse the caller's original parameters on paginated and recovery calls respectively. + result.value.groups.forEach { (key, group) -> + // A request without `next`/`prev` cursors for this key (or no per-group query at all) + // is a first-page request → replace channels. With a cursor → paginated → append. + val perGroupQuery = groups?.get(key) + val isFirstPage = perGroupQuery?.let { it.next == null && it.prev == null } ?: true + val perGroupLimit = perGroupQuery?.limit + val queryLogic = logic.queryChannels(QueryChannelsIdentifier.Grouped(key)) + queryLogic.setGroupedQueryConfig( + GroupedQueryConfig( + limit = limit, + pageSize = perGroupLimit, + watch = watch, + presence = presence, + ), + ) + queryLogic.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 a74901a8dbf..52795453f53 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 @@ -29,6 +29,7 @@ import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.querysort.QuerySorter +import io.getstream.chat.android.state.event.handler.grouped.internal.GroupAwareChatEventHandlerFactory import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelLogicImpl import io.getstream.chat.android.state.plugin.logic.channel.internal.ChannelStateLogic @@ -77,8 +78,19 @@ internal class LogicRegistry internal constructor( /** Returns [QueryChannelsLogic] for the given [identifier], creating it on first access. */ internal fun queryChannels(identifier: QueryChannelsIdentifier): QueryChannelsLogic { return queryChannels.getOrPut(identifier) { + val mutableState = stateRegistry.queryChannels(identifier).toMutableState() + // Idempotent default install: if a caller (e.g. ChatClientStateCalls) has already set a + // custom factory on the state, do not clobber it here. + if (identifier is QueryChannelsIdentifier.Grouped && + mutableState.chatEventHandlerFactory == null + ) { + mutableState.chatEventHandlerFactory = GroupAwareChatEventHandlerFactory( + groupKey = identifier.groupKey, + clientState = clientState, + ) + } val queryChannelsStateLogic = QueryChannelsStateLogic( - mutableState = stateRegistry.queryChannels(identifier).toMutableState(), + mutableState = mutableState, stateRegistry = stateRegistry, logicRegistry = this, coroutineScope = coroutineScope, 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 8b835a1f167..847258a3164 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 @@ -66,6 +66,8 @@ internal class QueryChannelsDatabaseLogic( queryChannelsRepository.selectBy(identifier.filter, identifier.sort) is QueryChannelsIdentifier.Predefined -> queryChannelsRepository.selectBy(identifier.name, identifier.filterValues, identifier.sortValues) + is QueryChannelsIdentifier.Grouped -> + queryChannelsRepository.selectBy(identifier.groupKey) } ?: return null val channels = repositoryFacade .selectChannels(spec.cids.toList(), pagination) 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 142f7e2dee5..f9534113645 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 @@ -26,12 +26,17 @@ import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationReq 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.chat.android.state.plugin.state.querychannels.GroupedQueryConfig import io.getstream.log.taggedLogger import io.getstream.result.Result import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock private const val INITIAL_CHANNEL_OFFSET = 0 private const val CHANNEL_LIMIT = 30 @@ -46,6 +51,66 @@ internal class QueryChannelsLogic( private val logger by taggedLogger("Chat:QueryChannelsLogic") + /** + * Serialises [applyGroupedResult] so concurrent grouped responses (e.g. recovery overlapping + * with pagination) cannot interleave their multi-step writes to [queryChannelsStateLogic]. + */ + private val groupedResultMutex = Mutex() + + /** + * 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()) + // 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) + groupedResultMutex.withLock { + 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." } @@ -63,7 +128,8 @@ internal class QueryChannelsLogic( else -> { // For predefined queries this restores the last persisted resolved filter/sort so // cached channels are sorted correctly before any network response. Not invoked for - // standard queries, as we already know the spec beforehand. + // standard or grouped queries — Standard's spec is fixed at construction; Grouped + // doesn't reach this path in practice (its listener routes via applyGroupedResult). if (cached.spec.predefinedFilterName != null) { applyResolvedSpec(cached.spec.filter, cached.spec.querySort) } @@ -85,18 +151,46 @@ internal class QueryChannelsLogic( queryChannelsStateLogic.setCurrentRequest(request) } + internal fun groupKey(): String? = (identifier as? QueryChannelsIdentifier.Grouped)?.groupKey + + internal fun groupedQueryConfig(): GroupedQueryConfig? = queryChannelsStateLogic.getGroupedQueryConfig() + + internal fun setGroupedQueryConfig(config: GroupedQueryConfig) { + queryChannelsStateLogic.setGroupedQueryConfig(config) + } + + internal fun currentRequest(): QueryChannelsRequest? = queryChannelsStateLogic.getState().currentRequest.value + internal fun recoveryNeeded(): StateFlow { return queryChannelsStateLogic.getState().recoveryNeeded } /** * Forwards the resolved filter/sort to the state logic. Called by the listener with values - * from `QueryChannelsResult.predefinedFilter`. A no-op for standard queries. + * from `QueryChannelsResult.predefinedFilter`. A no-op for standard and grouped queries (the + * state-logic guard short-circuits non-Predefined identifiers). */ internal fun applyResolvedSpec(filter: FilterObject, sort: QuerySorter) { queryChannelsStateLogic.applyResolvedSpec(filter, sort) } + /** + * Reads cached channels for this query's [identifier] from the offline DB and returns them. + * Returns `null` when no spec is persisted under the identifier. + */ + private suspend fun fetchChannelsFromCache(pagination: AnyChannelPaginationRequest): List? { + val channels = queryChannelsDatabaseLogic.fetchChannelsFromCache(pagination, identifier)?.channels + logger.i { + val message = if (channels == null) { + "no channels found in the local storage" + } else { + "${channels.size} channels found in the local storage" + } + "[fetchChannelsFromCache] $message" + } + return channels + } + /** * Adds a new channel to the query. * @@ -106,6 +200,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. * @@ -126,6 +230,49 @@ 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}" + } + + groupedResultMutex.withLock { + if (isFirstPage) { + val existing = queryChannelsStateLogic.getChannels() + if (!existing.isNullOrEmpty()) { + queryChannelsStateLogic.removeChannels(existing.keys) + } + queryChannelsStateLogic.setCids(emptySet()) + // Defensive: Grouped uses cursor pagination, not offset. Resetting guards against any + // future cross-path leakage from a Standard offset query mistakenly sharing this state. + queryChannelsStateLogic.setChannelsOffset(0) + } + + queryChannelsStateLogic.setNextCursor(group.next) + queryChannelsStateLogic.setEndOfChannels(group.next == null) + queryChannelsStateLogic.addChannelsState(channels) + 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) @@ -145,7 +292,8 @@ internal class QueryChannelsLogic( * * Rebuilds the request from the [identifier] so the request stays consistent with how this * logic was registered: standard queries rebuild from filter/sort, predefined queries from - * the predefined name + value maps (filter/querySort default; backend ignores them). + * the predefined name + value maps (filter/querySort default; backend ignores them). Grouped + * identifiers short-circuit — the grouped path uses `queryGroupedChannels` instead. */ internal suspend fun queryFirstPage(): Result> { logger.d { "[queryFirstPage] no args" } @@ -170,6 +318,10 @@ internal class QueryChannelsLogic( messageLimit = messageLimit, memberLimit = memberLimit, ) + is QueryChannelsIdentifier.Grouped -> { + logger.v { "[queryFirstPage] no-op for Grouped identifier" } + return Result.Success(emptyList()) + } } queryChannelsStateLogic.setCurrentRequest(request) @@ -302,6 +454,8 @@ internal class QueryChannelsLogic( messageLimit = 0, memberLimit = 0, ) + // Grouped queries do not use offset pagination; this path is unreachable in practice. + is QueryChannelsIdentifier.Grouped -> return emptyList() } return when (val result = client.queryChannelsInternal(request).await()) { is Result.Success -> result.value.channels 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 3cf2e5fa12b..1b6953907b3 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 @@ -30,6 +30,7 @@ import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.state.plugin.state.StateRegistry +import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.log.taggedLogger @@ -135,6 +136,20 @@ internal class QueryChannelsStateLogic( mutableState.setChannelsOffset(offset) } + internal fun setNextCursor(cursor: String?) { + mutableState.setNextCursor(cursor) + } + + internal fun setGroupedQueryConfig(config: GroupedQueryConfig) { + mutableState.setGroupedQueryConfig(config) + } + + internal fun getGroupedQueryConfig(): GroupedQueryConfig? = mutableState.groupedQueryConfig.value + + internal fun setCids(cids: Set) { + mutableState.setCids(cids) + } + /** * Increments the channels offset. * @@ -222,6 +237,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 f093c6328b1..77eb64bdd75 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 @@ -24,17 +24,16 @@ import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdent import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.FilterObject -import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.User -import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.chat.android.state.event.handler.internal.batch.BatchEvent import io.getstream.chat.android.state.plugin.config.MessageLimitConfig 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.WatchedChannelStateFlow 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 @@ -44,6 +43,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.StateFlow +import java.util.Collections +import java.util.WeakHashMap import java.util.concurrent.ConcurrentHashMap /** @@ -76,6 +77,9 @@ public class StateRegistry( ConcurrentHashMap() private val threads: ConcurrentHashMap = ConcurrentHashMap() + private val watchedChannelFlows: MutableMap = + Collections.synchronizedMap(WeakHashMap()) + /** * Returns [QueryChannelsState] associated with particular [filter] and [sort]. * @@ -89,29 +93,16 @@ public class StateRegistry( /** * Returns [QueryChannelsState] associated with the given [identifier]. Canonical lookup that - * works for both standard and predefined-filter queries. For predefined queries the resulting - * state starts with placeholder filter/sort that get replaced via `applyResolvedSpec` once - * the server response (or a previously persisted DB row) provides the resolved values. + * works for standard, predefined-filter, and grouped queries. [QueryChannelsMutableState] + * derives its initial filter/sort and spec shape from the identifier itself, so this method + * is just a registry-cache lookup keyed by identifier identity. * * @param identifier The identifier of the [QueryChannelsState]. */ @InternalStreamChatApi public fun queryChannels(identifier: QueryChannelsIdentifier): QueryChannelsState { return queryChannels.getOrPut(identifier) { - val (initialFilter, initialSort) = when (identifier) { - // Use known filter + sort - is QueryChannelsIdentifier.Standard -> identifier.filter to identifier.sort - // Use temporary neutral filter + sort - is QueryChannelsIdentifier.Predefined -> Filters.neutral() to QuerySortByField() - } - QueryChannelsMutableState( - identifier = identifier, - initialFilter = initialFilter, - initialSort = initialSort, - scope = scope, - latestUsers = latestUsers, - activeLiveLocations = activeLiveLocations, - ) + QueryChannelsMutableState(identifier, scope, latestUsers, activeLiveLocations) } } @@ -212,6 +203,28 @@ 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 entry lives as long as the caller holds the [flow]; once the caller releases it, the + * underlying [WeakHashMap] drops the entry automatically. + * Used during reconnect to re-watch only channels the user still has open. + * + * @param flow The [WatchedChannelStateFlow] identifying the watched channel. + */ + internal fun trackWatchedChannel(flow: WatchedChannelStateFlow) { + watchedChannelFlows[flow] = flow.cid + } + + /** + * 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(watchedChannelFlows) { + return watchedChannelFlows.values.toSet() + } + } + /** * Clear state of all state objects. */ @@ -225,6 +238,7 @@ public class StateRegistry( queryThreads.clear() threads.forEach { it.value.destroy() } threads.clear() + watchedChannelFlows.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 fc4b986f680..4a9f125f422 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,9 +22,11 @@ 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.internal.state.plugin.identifier import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.internal.logic import io.getstream.chat.android.state.extensions.state import io.getstream.chat.android.state.model.querychannels.pagination.internal.QueryChannelPaginationRequest import io.getstream.chat.android.state.plugin.state.StateRegistry @@ -71,6 +73,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/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..af64c8695ab --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/internal/WatchedChannelStateFlow.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.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 returned by [io.getstream.chat.android.state.extensions.watchChannelAsState]. + * Identifies a watched channel by [cid]; used as a weak key in + * [io.getstream.chat.android.state.plugin.state.StateRegistry]'s tracker so the entry is evicted + * automatically once the caller releases the returned flow. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class WatchedChannelStateFlow( + private val delegate: StateFlow, + val cid: String, +) : StateFlow by delegate diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig.kt new file mode 100644 index 00000000000..0c56658f44c --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/querychannels/GroupedQueryConfig.kt @@ -0,0 +1,39 @@ +/* + * 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 + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.models.GroupedChannelsGroupQuery + +/** + * Configuration captured from the most recent [ChatClient.queryGroupedChannels] request that + * targeted a specific group. Subsequent paginated and recovery calls read this back so they + * reuse the original parameters the caller chose. + * + * @property limit Request-level fallback limit applied to every group that doesn't specify its + * own override. + * @property pageSize This group's per-group override ([GroupedChannelsGroupQuery.limit]). `null` + * when only the request-level [limit] was specified. + * @property watch Whether the request asked to subscribe to channel events. + * @property presence Whether the request asked to subscribe to presence events. + */ +public data class GroupedQueryConfig( + public val limit: Int?, + public val pageSize: Int?, + public val watch: Boolean, + public val presence: Boolean, +) 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..402f57c0a20 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_updated` 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,22 @@ 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 + + /** + * Configuration captured from the most recent grouped query response that targeted this + * group. Used by paginated and recovery calls so they reuse the caller's original + * [GroupedQueryConfig.limit], [GroupedQueryConfig.pageSize], [GroupedQueryConfig.watch] and + * [GroupedQueryConfig.presence]. + * + * `null` for standard queries and until the first grouped response has been observed. + */ + public val groupedQueryConfig: 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 e4ade7e3bbe..a8882b51b9d 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 @@ -24,15 +24,17 @@ import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdent 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.GroupedQueryConfig 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 @@ -42,61 +44,71 @@ import kotlinx.coroutines.flow.stateIn /** * Mutable backing state for a query channels operation. Each instance corresponds to a unique - * [QueryChannelsIdentifier] (Standard or Predefined). + * [QueryChannelsIdentifier] (Standard, Predefined, or Grouped). Initial spec, filter, and sort + * are derived from the identifier — callers only pass the identifier itself. * - * For [QueryChannelsIdentifier.Standard], `initialFilter`/`initialSort` come from the client and - * are immutable across the lifetime of this state — [applyResolvedSpec] is effectively a no-op. + * For [QueryChannelsIdentifier.Standard], `filter`/`sort` come from the identifier and are + * immutable across the lifetime of this state — [applyResolvedSpec] is a no-op. * - * For [QueryChannelsIdentifier.Predefined], `initialFilter`/`initialSort` are placeholders - * (defaults supplied by the registry) until [applyResolvedSpec] is called either with the - * server-resolved values from `QueryChannelsResult.predefinedFilter` or with values rehydrated - * from the offline DB. The internal `_sort` flow drives the sorted channel list, so re-sorting - * happens automatically once the resolved sort is applied. + * For [QueryChannelsIdentifier.Predefined], `filter`/`sort` start as neutral placeholders until + * [applyResolvedSpec] is called either with the server-resolved values from + * `QueryChannelsResult.predefinedFilter` or with values rehydrated from the offline DB. The + * internal `_sort` flow drives the sorted channel list, so re-sorting happens automatically once + * the resolved sort is applied. + * + * For [QueryChannelsIdentifier.Grouped], `filter` is neutral and `sort` defaults to + * `last_updated` descending. Channels are populated via the listener-driven grouped-channels + * endpoint, and the cursor lives on [_nextCursor]. */ internal class QueryChannelsMutableState( val identifier: QueryChannelsIdentifier, - initialFilter: FilterObject, - initialSort: QuerySorter, scope: CoroutineScope, latestUsers: StateFlow>, activeLiveLocations: StateFlow>, ) : QueryChannelsState { - private val logger by taggedLogger("Chat:QueryChannelsState") - - private val _filter: MutableStateFlow = MutableStateFlow(initialFilter) - private val _sort: MutableStateFlow> = MutableStateFlow(initialSort) - - override val filter: FilterObject - get() = _filter.value - override val sort: QuerySorter - get() = _sort.value - - internal var rawChannels: Map? - get() = _channels?.value - private set(value) { - _channels?.value = value - } - /** - * In-memory cache spec for the active query. + * In-memory cache spec for the active query. Carries variant-specific identity fields + * (`groupKey` for Grouped, `predefinedFilter*` for Predefined) so they survive + * [QueryChannelsSpec] round-trips and DB persistence. */ private var _querySpec: QueryChannelsSpec = when (identifier) { is QueryChannelsIdentifier.Standard -> QueryChannelsSpec( - filter = initialFilter, - querySort = initialSort, + filter = identifier.filter, + querySort = identifier.sort, ) is QueryChannelsIdentifier.Predefined -> QueryChannelsSpec( - filter = initialFilter, - querySort = initialSort, + filter = Filters.neutral(), + querySort = QuerySortByField(), predefinedFilterName = identifier.name, predefinedFilterValues = identifier.filterValues, predefinedSortValues = identifier.sortValues, ) + is QueryChannelsIdentifier.Grouped -> QueryChannelsSpec( + filter = Filters.neutral(), + querySort = QuerySortByField.descByName("last_updated"), + groupKey = identifier.groupKey, + ) } + + /** Spec backing this state. [QueryChannelsSpec.cids] is mutated in place via [setCids]. */ internal val queryChannelsSpec: QueryChannelsSpec get() = _querySpec + private val _filter: MutableStateFlow = MutableStateFlow(_querySpec.filter) + private val _sort: MutableStateFlow> = MutableStateFlow(_querySpec.querySort) + + override val filter: FilterObject + get() = _filter.value + override val sort: QuerySorter + get() = _sort.value + + internal var rawChannels: Map? + get() = _channels?.value + private set(value) { + _channels?.value = value + } + /** * Property that exposes a map of raw channels. * The channels are later sorted and enriched with latest users updates @@ -127,17 +139,25 @@ internal class QueryChannelsMutableState( private var _channelsOffset: MutableStateFlow? = MutableStateFlow(0) internal val channelsOffset: StateFlow = _channelsOffset!! + private var _nextCursor: MutableStateFlow? = MutableStateFlow(null) + private var _groupedQueryConfig: 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) @@ -147,6 +167,8 @@ 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 groupedQueryConfig: StateFlow = _groupedQueryConfig!! override val channels: StateFlow?> = sortedChannels override val channelsStateData: StateFlow = loading.combine(sortedChannels) { loading: Boolean, channels: List? -> @@ -188,7 +210,7 @@ internal class QueryChannelsMutableState( /** * Set the end of channels. * - * @parami isEnd Boolean + * @param isEnd Boolean */ fun setEndOfChannels(isEnd: Boolean) { _endOfChannels?.value = isEnd @@ -221,18 +243,34 @@ internal class QueryChannelsMutableState( rawChannels = channelsMap } + /** + * Set the next-page cursor. Used by the grouped-channels path; the standard and predefined + * paths don't publish a cursor here. + */ + fun setNextCursor(cursor: String?) { + _nextCursor?.value = cursor + } + + /** + * Store the configuration that produced the current page of grouped results. Read back by + * [io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel] for paginated + * calls and by [io.getstream.chat.android.state.sync.internal.SyncManager] for recovery. + */ + fun setGroupedQueryConfig(config: GroupedQueryConfig) { + _groupedQueryConfig?.value = config + } + /** * Applies the resolved filter/sort to the state. Only relevant for predefined-filter queries, * where the actual filter/sort are not known until either: * - The server response arrives carrying `QueryChannelsResult.predefinedFilter`, or * - The offline DB rehydrates a previously persisted resolved spec for the same identifier. * - * No-op for [QueryChannelsIdentifier.Standard] queries — their filter/sort are fixed at - * construction time and must not be replaced. + * No-op for [QueryChannelsIdentifier.Standard] and [QueryChannelsIdentifier.Grouped] queries — + * their filter/sort are fixed at construction time and must not be replaced. * - * Because [QueryChannelsSpec] keeps `filter` and `querySort` as `val` for binary - * compatibility, we replace the held [_querySpec] instance instead of mutating it in place. - * `cids` and the predefined identity fields are carried over from the previous instance. + * The 2-arg [QueryChannelsSpec.copy] preserves variant-specific identity fields (predefined + * name/values, groupKey) and `cids` from the previous spec instance. */ fun applyResolvedSpec(filter: FilterObject, sort: QuerySorter) { if (identifier !is QueryChannelsIdentifier.Predefined) return @@ -241,12 +279,9 @@ internal class QueryChannelsMutableState( _querySpec = _querySpec.copy(filter = filter, querySort = sort) } - /** - * Replaces the held [_querySpec] with a copy whose [QueryChannelsSpec.cids] are updated to - * [cids]. Required because [QueryChannelsSpec] is now fully immutable. - */ - fun setCids(cids: Set) { - _querySpec = _querySpec.copy(cids = cids) + /** Updates [QueryChannelsSpec.cids] on the held spec. */ + internal fun setCids(cids: Set) { + _querySpec.cids = cids } fun destroy() { @@ -257,6 +292,8 @@ internal class QueryChannelsMutableState( _currentRequest = null _recoveryNeeded = null _channelsOffset = null + _nextCursor = null + _groupedQueryConfig = 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 47481cfcb4b..6b48dfb6966 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 @@ -43,6 +43,7 @@ import io.getstream.chat.android.core.internal.coroutines.Tube import io.getstream.chat.android.core.utils.date.diff import io.getstream.chat.android.models.Attachment import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.GroupedChannelsGroupQuery import io.getstream.chat.android.models.MemberData import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Reaction @@ -84,7 +85,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 +408,126 @@ 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, - ) + + val allLogics = logicRegistry.getActiveQueryChannelsLogic() + val hasGroupedQueries = allLogics.any { it.groupKey() != null } + val hasStandardQueries = allLogics.any { it.groupKey() == null } + + // --- GroupedQueryChannels path --- + val groupedHandledCids: Set = if (hasGroupedQueries) { + // Refresh first page of the queries populated via GroupedQueryChannels + val refreshed = updateGroupedQueryChannels(recoverAll) + // Re-watch tracked channels (specific for this path, where we don't re-watch the groups, just the manually + // opened/tracked channels) + val rewatched = rewatchTrackedWatchedChannels() + refreshed + rewatched + } else { + emptySet() + } + + // --- 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 + groupedHandledCids) + } + is Result.Failure -> { + logger.e { "[restoreActiveChannels] standard query failed: ${result.value}" } + return + } } + } + // --- Active Channels created outside of QueryChannels requests + if (!hasStandardQueries && !hasGroupedQueries) { + // Check for active channels created outside of a QueryChannels requests + updateActiveChannels(recoverAll, cidsToExclude = emptySet()) + } + } + + /** + * Drives the recovery flow for grouped channel queries: when at least one active grouped + * logic needs recovery, calls [ChatClient.queryGroupedChannelsInternal] once. The + * [io.getstream.chat.android.state.plugin.listener.internal.QueryGroupedChannelsListenerState] + * routes the response into the corresponding per-group state and persists it. + * + * Assumes all active grouped queries share the same request-level `limit`, `watch`, and + * `presence` flags — the first captured config wins. + */ + private suspend fun updateGroupedQueryChannels(recoverAll: Boolean): Set { + val activeGroupedLogics = logicRegistry.getActiveQueryChannelsLogic() + .filter { it.groupKey() != null && (it.recoveryNeeded().value || recoverAll) } + + if (activeGroupedLogics.isEmpty()) { + logger.v { "[updateGroupedQueryChannels] no grouped queries to restore" } + return emptySet() + } + + // Shared fields (limit, watch, presence) are identical across groups from the same + // original session — take the first captured config. Per-group page sizes are merged in + // the `groups` map below so the server returns the same page sizes the caller chose. + // + // Always include every active group's key, even when the captured per-group `pageSize` is + // null. An empty `{}` per-group entry is meaningful: it tells the server "refresh this + // group too". Filtering it out would cause re-sync to skip that group entirely. + val shared = activeGroupedLogics.firstNotNullOfOrNull { it.groupedQueryConfig() } + val groupsParam = activeGroupedLogics + .mapNotNull { logic -> + val key = logic.groupKey() ?: return@mapNotNull null + val cfg = logic.groupedQueryConfig() + key to GroupedChannelsGroupQuery(limit = cfg?.pageSize) + } + .toMap() + .takeIf { it.isNotEmpty() } + + val result = chatClient.queryGroupedChannelsInternal( + limit = shared?.limit, + groups = groupsParam, + watch = shared?.watch ?: true, + presence = shared?.presence ?: false, + ).await() + + return when (result) { + is Result.Success -> { + val cids = result.value.groups.values + .flatMap { group -> group.channels } + .mapTo(mutableSetOf()) { it.cid } + logger.v { "[updateGroupedQueryChannels] succeeded; cids.size: ${cids.size}" } + cids + } is Result.Failure -> { - logger.e { "[restoreActiveChannels] failed: ${result.value}" } - return + logger.e { "[updateGroupedQueryChannels] queryGroupedChannelsInternal failed: ${result.value}" } + emptySet() } } } + /** + * Re-watches channels explicitly opened by the user (tracked via + * [io.getstream.chat.android.state.plugin.state.internal.WatchedChannelStateFlow] weak references in + * [StateRegistry]). + */ + private suspend fun rewatchTrackedWatchedChannels(): Set { + val online = clientState.isOnline + val watchedCids = stateRegistry.getTrackedWatchedChannels() + logger.d { "[rewatchTrackedWatchedChannels] watchedCids.size: ${watchedCids.size}, online: $online" } + if (watchedCids.isEmpty() || !online) return emptySet() + + watchedCids.forEach { cid -> + val (type, id) = cid.cidToTypeAndId() + logicRegistry.channel(type, id).watch(userPresence = userPresence) + } + return watchedCids + } + 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/grouped/internal/DefaultChannelGroupResolverTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/DefaultChannelGroupResolverTest.kt new file mode 100644 index 00000000000..0d356686ced --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/DefaultChannelGroupResolverTest.kt @@ -0,0 +1,74 @@ +/* + * 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.grouped.internal + +import io.getstream.chat.android.randomChannel +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class DefaultChannelGroupResolverTest { + + @Test + fun `Given a channel with an explicit group When resolved Then returns the group plus the all sentinel`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channel) + + assertEquals(setOf("vip", "all"), result) + } + + @Test + fun `Given a channel with no group extra When resolved Then returns only the all sentinel`() { + val channel = randomChannel(extraData = emptyMap()) + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channel) + + assertEquals(setOf("all"), result) + } + + @Test + fun `Given a custom group field name When resolved Then reads that field`() { + val channel = randomChannel(extraData = mapOf("tier" to "gold", "group" to "ignored")) + val resolver = DefaultChannelGroupResolver(groupFieldName = "tier") + + val result = resolver.resolve(channel) + + assertEquals(setOf("gold", "all"), result) + } + + @Test + fun `Given the all sentinel is disabled When resolved Then returns only the explicit group`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val resolver = DefaultChannelGroupResolver(allGroupKey = null) + + val result = resolver.resolve(channel) + + assertEquals(setOf("vip"), result) + } + + @Test + fun `Given a non-string group extra When resolved Then ignores it and returns only the all sentinel`() { + val channel = randomChannel(extraData = mapOf("group" to 42)) + val resolver = DefaultChannelGroupResolver() + + val result = resolver.resolve(channel) + + assertEquals(setOf("all"), result) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt new file mode 100644 index 00000000000..75e1808cbfe --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -0,0 +1,308 @@ +/* + * 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.grouped.internal + +import io.getstream.chat.android.client.setup.state.ClientState +import io.getstream.chat.android.client.test.randomChannelDeletedEvent +import io.getstream.chat.android.client.test.randomChannelUpdatedEvent +import io.getstream.chat.android.client.test.randomMemberAddedEvent +import io.getstream.chat.android.client.test.randomMemberRemovedEvent +import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent +import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +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 currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser)) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given channel belongs to this group and is already cached When ChannelUpdatedEvent arrives Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser)) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given channel moved to another group and is currently cached When ChannelUpdatedEvent arrives Should remove`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = mapOf(channel.cid to channel)) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given channel belongs to another group and is not cached When ChannelUpdatedEvent arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given handler is for the all group When ChannelUpdatedEvent arrives Should always add`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser)) + val handler = handlerFor( + groupKey = "all", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given channel does not belong here When NotificationAddedToChannelEvent arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given channel belongs here When NotificationAddedToChannelEvent arrives Should watch and add`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given channel does not belong here When NotificationMessageNewEvent arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationMessageNewEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given current user joined with matching cached channel When MemberAddedEvent arrives Should add`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomMemberAddedEvent( + cid = channel.cid, + member = randomMember(user = currentUser), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given current user joined with non-matching cached channel When MemberAddedEvent arrives Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomMemberAddedEvent( + cid = channel.cid, + member = randomMember(user = currentUser), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given current user left a cached channel When MemberRemovedEvent arrives Should remove regardless of group`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = randomMemberRemovedEvent( + cid = channel.cid, + member = randomMember(user = currentUser), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given cachedChannel has no membership for current user When ChannelUpdatedEvent moves channel into this group Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "old")) + val cachedChannel = channel.copy(membership = null) + val handler = handlerFor( + groupKey = "old", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given current user lost membership while channel still resolves here When ChannelUpdatedEvent arrives Should remove`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "old")) + val cachedChannel = channel.copy(membership = null) + val handler = handlerFor( + groupKey = "old", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given current user has a connection but no cachedChannel When ChannelUpdatedEvent arrives Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "old")) + val handler = handlerFor( + groupKey = "old", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a cached channel When ChannelDeletedEvent arrives Should remove regardless of group`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + ) + val event = randomChannelDeletedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a custom resolver that reads a different field When ChannelUpdatedEvent arrives Should use custom field`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("tier" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser)) + val customResolver = ChannelGroupResolver { ch -> + setOfNotNull(ch.extraData["tier"] as? String) + } + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + resolver = customResolver, + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + 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/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt new file mode 100644 index 00000000000..b156271184a --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt @@ -0,0 +1,614 @@ +/* + * 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.grouped.internal + +import io.getstream.chat.android.client.events.ChannelUpdatedByUserEvent +import io.getstream.chat.android.client.events.ChannelUpdatedEvent +import io.getstream.chat.android.client.events.HasGroupedUnreadChannels +import io.getstream.chat.android.client.events.MarkAllReadEvent +import io.getstream.chat.android.client.events.NewMessageEvent +import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent +import io.getstream.chat.android.client.events.NotificationMarkReadEvent +import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.GroupedChannels +import io.getstream.chat.android.models.GroupedChannelsGroup +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomChannelUserRead +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.state.plugin.state.StateRegistry +import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import java.util.Date + +private const val CURRENT_USER_ID = "user-1" +private const val OTHER_USER_ID = "user-2" +private const val CHANNEL_TYPE = "messaging" +private const val CHANNEL_ID = "channel-id" +private const val CID = "$CHANNEL_TYPE:$CHANNEL_ID" +private const val BATCH_ID = 1 +private const val NEXT_BATCH_ID = 2 + +internal class GroupedUnreadChannelsUpdaterTest { + + private val stateRegistry: StateRegistry = mock() + private val updater = GroupedUnreadChannelsUpdater(stateRegistry, CURRENT_USER_ID) + + // region HasGroupedUnreadChannels overload + + @Test + fun `HasGroupedUnreadChannels event with non-null map replaces current map`() { + val current = mapOf("a" to 1, "b" to 2) + val event = hasGroupedUnreadChannels(mapOf("a" to 5, "c" to 3)) + + val result = updater.calculateUpdatedCounts(current, BATCH_ID, event) + + assertEquals(mapOf("a" to 5, "c" to 3), result) + } + + @Test + fun `HasGroupedUnreadChannels event with null map returns current map unchanged`() { + val current = mapOf("a" to 1, "b" to 2) + val event = hasGroupedUnreadChannels(null) + + val result = updater.calculateUpdatedCounts(current, BATCH_ID, event) + + assertSame(current, result) + } + + // endregion + + // region GroupedChannels (query result) overload + + @Test + fun `GroupedChannels result merges per-group counts into current map`() { + val current = mapOf("a" to 1, "b" to 2, "c" to 3) + val result = GroupedChannels( + groups = mapOf( + "b" to groupedChannelsGroup("b", unreadChannels = 7), + "d" to groupedChannelsGroup("d", unreadChannels = 4), + ), + ) + + val next = updater.calculateUpdatedCounts(current, result) + + // a/c preserved (not in result), b/d overwritten/added + assertEquals(mapOf("a" to 1, "b" to 7, "c" to 3, "d" to 4), next) + } + + @Test + fun `GroupedChannels result with empty groups returns current map unchanged`() { + val current = mapOf("a" to 1) + val result = GroupedChannels(groups = emptyMap()) + + val next = updater.calculateUpdatedCounts(current, result) + + assertEquals(current, next) + } + + // endregion + + // region ChannelUpdatedEvent / ChannelUpdatedByUserEvent overloads + + @Test + fun `ChannelUpdatedEvent migrates count from old group to new group when channel had unread`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 3) + val current = mapOf("a" to 5, "b" to 2) + val newChannel = channel(group = "b") + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(newChannel)) + + assertEquals(mapOf("a" to 4, "b" to 3), next) + } + + @Test + fun `ChannelUpdatedEvent with group set for first time only increments new group`() { + seedActiveChannel(oldGroup = null, unreadMessages = 1) + val current = mapOf("b" to 0) + val newChannel = channel(group = "b") + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(newChannel)) + + assertEquals(mapOf("b" to 1), next) + } + + @Test + fun `ChannelUpdatedEvent with group removed only decrements old group`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 2) + val current = mapOf("a" to 2) + val newChannel = channel(group = null) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(newChannel)) + + assertEquals(mapOf("a" to 1), next) + } + + @Test + fun `ChannelUpdatedEvent with unchanged group returns current map unchanged`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val current = mapOf("a" to 5) + val newChannel = channel(group = "a") + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(newChannel)) + + assertSame(current, next) + } + + @Test + fun `ChannelUpdatedEvent with no unread on cached channel returns current map unchanged`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 0) + val current = mapOf("a" to 5, "b" to 1) + val newChannel = channel(group = "b") + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(newChannel)) + + assertSame(current, next) + } + + @Test + fun `ChannelUpdatedEvent decrement is clamped at zero`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + // Edge: backend-pushed map has 0 for "a" yet cached channel still claims unread. + val current = mapOf("a" to 0) + val newChannel = channel(group = "b") + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(newChannel)) + + assertEquals(mapOf("a" to 0, "b" to 1), next) + } + + @Test + fun `ChannelUpdatedEvent for inactive channel returns current map unchanged`() { + whenever(stateRegistry.isActiveChannel(CHANNEL_TYPE, CHANNEL_ID)) doReturn false + val current = mapOf("a" to 5) + val newChannel = channel(group = "b") + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(newChannel)) + + assertSame(current, next) + } + + @Test + fun `ChannelUpdatedByUserEvent applies the same migration semantics`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 2) + val current = mapOf("a" to 3, "b" to 1) + val newChannel = channel(group = "b") + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedByUserEvent(newChannel)) + + assertEquals(mapOf("a" to 2, "b" to 2), next) + } + + // endregion + + // region Same-batch dedup + + @Test + fun `Two ChannelUpdatedEvent for same cid in one batch apply migration only once`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val newChannel = channel(group = "b") + val event = channelUpdatedEvent(newChannel) + val initial = mapOf("a" to 5, "b" to 0) + + val afterFirst = updater.calculateUpdatedCounts(initial, BATCH_ID, event) + val afterSecond = updater.calculateUpdatedCounts(afterFirst, BATCH_ID, event) + + // First call applies the delta; second call is suppressed by processedCids. + assertEquals(mapOf("a" to 4, "b" to 1), afterFirst) + assertSame(afterFirst, afterSecond) + } + + @Test + fun `Same channel updated in two different batches applies delta in each`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val newChannel = channel(group = "b") + val event = channelUpdatedEvent(newChannel) + val initial = mapOf("a" to 5, "b" to 0) + + val afterFirst = updater.calculateUpdatedCounts(initial, BATCH_ID, event) + val afterSecond = updater.calculateUpdatedCounts(afterFirst, NEXT_BATCH_ID, event) + + assertEquals(mapOf("a" to 4, "b" to 1), afterFirst) + assertEquals(mapOf("a" to 3, "b" to 2), afterSecond) + } + + @Test + fun `No-op ChannelUpdatedEvent does not consume the per-batch dedup slot`() { + // First event is a no-op (same group as cached); a later event with a real group change + // for the same cid should still apply, because we only mark processed on real mutation. + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val noop = channelUpdatedEvent(channel(group = "a")) + val real = channelUpdatedEvent(channel(group = "b")) + val initial = mapOf("a" to 5, "b" to 0) + + val afterNoop = updater.calculateUpdatedCounts(initial, BATCH_ID, noop) + val afterReal = updater.calculateUpdatedCounts(afterNoop, BATCH_ID, real) + + assertSame(initial, afterNoop) + assertEquals(mapOf("a" to 4, "b" to 1), afterReal) + } + + // endregion + + // region HGUC subtype side effects on overrides + + @Test + fun `mark_read before channel updated in same batch suppresses delta`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val initial = mapOf("a" to 5, "b" to 0) + val markRead = notificationMarkReadEvent(map = mapOf("a" to 4, "b" to 0)) + val update = channelUpdatedEvent(channel(group = "b")) + + val afterMarkRead = updater.calculateUpdatedCounts(initial, BATCH_ID, markRead) + val afterUpdate = updater.calculateUpdatedCounts(afterMarkRead, BATCH_ID, update) + + assertEquals(mapOf("a" to 4, "b" to 0), afterMarkRead) + assertSame(afterMarkRead, afterUpdate) + } + + @Test + fun `mark_unread before channel updated in same batch allows delta`() { + // Cached channel has unread=0; without override the delta would skip. + // mark_unread flips override to true so delta fires. + seedActiveChannel(oldGroup = "a", unreadMessages = 0) + val initial = mapOf("a" to 5, "b" to 0) + val markUnread = notificationMarkUnreadEvent(map = mapOf("a" to 6, "b" to 0)) + val update = channelUpdatedEvent(channel(group = "b")) + + val afterMarkUnread = updater.calculateUpdatedCounts(initial, BATCH_ID, markUnread) + val afterUpdate = updater.calculateUpdatedCounts(afterMarkUnread, BATCH_ID, update) + + assertEquals(mapOf("a" to 6, "b" to 0), afterMarkUnread) + assertEquals(mapOf("a" to 5, "b" to 1), afterUpdate) + } + + @Test + fun `new message from other user before channel updated in same batch allows delta`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 0) + val initial = mapOf("a" to 5, "b" to 0) + val newMessage = newMessageEvent(senderUserId = OTHER_USER_ID, map = mapOf("a" to 6, "b" to 0)) + val update = channelUpdatedEvent(channel(group = "b")) + + val afterNewMessage = updater.calculateUpdatedCounts(initial, BATCH_ID, newMessage) + val afterUpdate = updater.calculateUpdatedCounts(afterNewMessage, BATCH_ID, update) + + assertEquals(mapOf("a" to 6, "b" to 0), afterNewMessage) + assertEquals(mapOf("a" to 5, "b" to 1), afterUpdate) + } + + @Test + fun `new message from current user does not flip unread override`() { + // Cached channel says unread=0 and sender is the current user, so no override. + // Delta then reads cached hadUnread=false and is a no-op. + seedActiveChannel(oldGroup = "a", unreadMessages = 0) + val initial = mapOf("a" to 5, "b" to 0) + val newMessage = newMessageEvent(senderUserId = CURRENT_USER_ID, map = mapOf("a" to 5, "b" to 0)) + val update = channelUpdatedEvent(channel(group = "b")) + + val afterNewMessage = updater.calculateUpdatedCounts(initial, BATCH_ID, newMessage) + val afterUpdate = updater.calculateUpdatedCounts(afterNewMessage, BATCH_ID, update) + + assertEquals(mapOf("a" to 5, "b" to 0), afterNewMessage) + assertSame(afterNewMessage, afterUpdate) + } + + @Test + fun `notification channel_deleted HGUC before channel updated suppresses delta`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val initial = mapOf("a" to 5, "b" to 0) + val deleted = notificationChannelDeletedEvent(map = mapOf("a" to 4, "b" to 0)) + val update = channelUpdatedEvent(channel(group = "b")) + + val afterDeleted = updater.calculateUpdatedCounts(initial, BATCH_ID, deleted) + val afterUpdate = updater.calculateUpdatedCounts(afterDeleted, BATCH_ID, update) + + assertEquals(mapOf("a" to 4, "b" to 0), afterDeleted) + assertSame(afterDeleted, afterUpdate) + } + + @Test + fun `notification channel_truncated before channel updated suppresses delta via override`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val initial = mapOf("a" to 5, "b" to 0) + val truncated = notificationChannelTruncatedEvent(map = mapOf("a" to 4, "b" to 0)) + val update = channelUpdatedEvent(channel(group = "b")) + + val afterTruncated = updater.calculateUpdatedCounts(initial, BATCH_ID, truncated) + val afterUpdate = updater.calculateUpdatedCounts(afterTruncated, BATCH_ID, update) + + assertEquals(mapOf("a" to 4, "b" to 0), afterTruncated) + assertSame(afterTruncated, afterUpdate) + } + + // endregion + + // region notifyChannelRemoved + + @Test + fun `notifyChannelRemoved before channel updated suppresses delta`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val current = mapOf("a" to 5, "b" to 0) + + updater.notifyChannelRemoved(BATCH_ID, CID) + val next = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(channel(group = "b"))) + + assertSame(current, next) + } + + // endregion + + // region MarkAllRead + + @Test + fun `MarkAllReadEvent before channel updated suppresses delta`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val initial = mapOf("a" to 5, "b" to 0) + + val afterMarkAllRead = updater.calculateUpdatedCounts(initial, BATCH_ID, markAllReadEvent(map = null)) + val next = updater.calculateUpdatedCounts( + afterMarkAllRead, + BATCH_ID, + channelUpdatedEvent(channel(group = "b")), + ) + + assertSame(afterMarkAllRead, next) + } + + @Test + fun `MarkAllReadEvent with authoritative map replaces map and suppresses subsequent delta`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val initial = mapOf("a" to 5, "b" to 0) + + val afterMarkAllRead = updater.calculateUpdatedCounts( + initial, + BATCH_ID, + markAllReadEvent(map = mapOf("a" to 0, "b" to 0)), + ) + val next = updater.calculateUpdatedCounts( + afterMarkAllRead, + BATCH_ID, + channelUpdatedEvent(channel(group = "b")), + ) + + assertEquals(mapOf("a" to 0, "b" to 0), afterMarkAllRead) + assertSame(afterMarkAllRead, next) + } + + @Test + fun `per-cid unread override after MarkAllReadEvent wins for that cid`() { + // MarkAllRead would normally suppress; a later new_message from another user re-sets + // the per-cid override to true, so the delta fires. + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val initial = mapOf("a" to 5, "b" to 0) + + val afterMarkAllRead = updater.calculateUpdatedCounts(initial, BATCH_ID, markAllReadEvent(map = null)) + val afterNewMessage = updater.calculateUpdatedCounts( + afterMarkAllRead, + BATCH_ID, + newMessageEvent(senderUserId = OTHER_USER_ID, map = mapOf("a" to 5, "b" to 0)), + ) + val afterUpdate = updater.calculateUpdatedCounts( + afterNewMessage, + BATCH_ID, + channelUpdatedEvent(channel(group = "b")), + ) + + assertEquals(mapOf("a" to 4, "b" to 1), afterUpdate) + } + + @Test + fun `MarkAllReadEvent clears prior new_message override for same cid`() { + // new_message first sets hadUnreadOverride[X]=true; MarkAllRead then clears it. + // Subsequent channel.updated should no-op because the global flag now fires. + seedActiveChannel(oldGroup = "a", unreadMessages = 0) + val initial = mapOf("a" to 5, "b" to 0) + + val afterNewMessage = updater.calculateUpdatedCounts( + initial, + BATCH_ID, + newMessageEvent(senderUserId = OTHER_USER_ID, map = mapOf("a" to 6, "b" to 0)), + ) + val afterMarkAllRead = updater.calculateUpdatedCounts(afterNewMessage, BATCH_ID, markAllReadEvent(map = null)) + val afterUpdate = updater.calculateUpdatedCounts( + afterMarkAllRead, + BATCH_ID, + channelUpdatedEvent(channel(group = "b")), + ) + + assertSame(afterMarkAllRead, afterUpdate) + } + + // endregion + + // region Batch rotation + + @Test + fun `dedup state is cleared when a new batch id arrives`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 1) + val current = mapOf("a" to 5, "b" to 0) + + updater.notifyChannelRemoved(BATCH_ID, CID) + val sameBatch = updater.calculateUpdatedCounts(current, BATCH_ID, channelUpdatedEvent(channel(group = "b"))) + val nextBatch = updater.calculateUpdatedCounts(current, NEXT_BATCH_ID, channelUpdatedEvent(channel(group = "b"))) + + assertSame(current, sameBatch) + assertEquals(mapOf("a" to 4, "b" to 1), nextBatch) + } + + // endregion + + // region helpers + + private fun seedActiveChannel(oldGroup: String?, unreadMessages: Int) { + val cached = channel(group = oldGroup).copy( + read = listOf( + randomChannelUserRead( + user = User(id = CURRENT_USER_ID), + unreadMessages = unreadMessages, + ), + ), + ) + val channelMutableState: ChannelMutableState = mock { + on { toChannel() } doReturn cached + } + whenever(stateRegistry.isActiveChannel(CHANNEL_TYPE, CHANNEL_ID)) doReturn true + whenever(stateRegistry.mutableChannel(CHANNEL_TYPE, CHANNEL_ID)) doReturn channelMutableState + } + + private fun channel(group: String?): Channel = randomChannel( + id = CHANNEL_ID, + type = CHANNEL_TYPE, + extraData = group?.let { mapOf("group" to it) } ?: emptyMap(), + read = emptyList(), + ) + + private fun channelUpdatedEvent(newChannel: Channel): ChannelUpdatedEvent = ChannelUpdatedEvent( + type = "channel.updated", + createdAt = Date(), + rawCreatedAt = "", + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + channel = newChannel, + message = null, + ) + + private fun channelUpdatedByUserEvent(newChannel: Channel): ChannelUpdatedByUserEvent = + ChannelUpdatedByUserEvent( + type = "channel.updated_by_user", + createdAt = Date(), + rawCreatedAt = "", + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + user = randomUser(), + channel = newChannel, + message = null, + ) + + private fun hasGroupedUnreadChannels(map: Map?): HasGroupedUnreadChannels = + notificationMarkReadEvent(map = map) + + private fun markAllReadEvent(map: Map?): MarkAllReadEvent = + MarkAllReadEvent( + type = "notification.mark_read", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = CURRENT_USER_ID), + totalUnreadCount = 0, + unreadChannels = 0, + groupedUnreadChannels = map, + ) + + private fun notificationMarkReadEvent(map: Map?): NotificationMarkReadEvent = + NotificationMarkReadEvent( + type = "notification.mark_read", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = CURRENT_USER_ID), + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + lastReadMessageId = null, + groupedUnreadChannels = map, + ) + + private fun notificationMarkUnreadEvent(map: Map?): NotificationMarkUnreadEvent = + NotificationMarkUnreadEvent( + type = "notification.mark_unread", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = CURRENT_USER_ID), + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + firstUnreadMessageId = "msg-1", + lastReadMessageAt = Date(), + lastReadMessageId = null, + unreadMessages = 1, + totalUnreadCount = 1, + unreadChannels = 1, + threadId = null, + unreadThreads = 0, + unreadThreadMessages = 0, + groupedUnreadChannels = map, + ) + + private fun newMessageEvent(senderUserId: String, map: Map?): NewMessageEvent = + NewMessageEvent( + type = "message.new", + createdAt = Date(), + rawCreatedAt = "", + user = User(id = senderUserId), + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + message = randomMessage(), + watcherCount = 0, + totalUnreadCount = 1, + unreadChannels = 1, + channelMessageCount = 1, + groupedUnreadChannels = map, + ) + + private fun notificationChannelDeletedEvent(map: Map?): NotificationChannelDeletedEvent = + NotificationChannelDeletedEvent( + type = "notification.channel_deleted", + createdAt = Date(), + rawCreatedAt = "", + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + channel = channel(group = "a"), + totalUnreadCount = 0, + unreadChannels = 0, + groupedUnreadChannels = map, + ) + + private fun notificationChannelTruncatedEvent(map: Map?): NotificationChannelTruncatedEvent = + NotificationChannelTruncatedEvent( + type = "notification.channel_truncated", + createdAt = Date(), + rawCreatedAt = "", + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + channel = channel(group = "a"), + totalUnreadCount = 0, + unreadChannels = 0, + groupedUnreadChannels = map, + ) + + private fun groupedChannelsGroup(key: String, unreadChannels: Int): GroupedChannelsGroup = + GroupedChannelsGroup( + groupKey = key, + channels = emptyList(), + unreadChannels = unreadChannels, + next = null, + prev = null, + ) + + // endregion +} 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 60c987d9f05..49e25661457 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 @@ -35,6 +35,7 @@ import io.getstream.chat.android.client.test.randomNotificationMarkReadEvent import io.getstream.chat.android.client.test.randomNotificationMarkUnreadEvent import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent import io.getstream.chat.android.client.test.randomNotificationMutesUpdatedEvent +import io.getstream.chat.android.client.test.randomNotificationRemovedFromChannelEvent import io.getstream.chat.android.client.test.randomPollDeletedEvent import io.getstream.chat.android.client.utils.observable.Disposable import io.getstream.chat.android.models.ChannelCapabilities @@ -48,6 +49,7 @@ import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomChannelMute import io.getstream.chat.android.randomLocation +import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomMute import io.getstream.chat.android.randomPoll @@ -162,6 +164,184 @@ 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 + } + + @Test + fun `ChannelUpdatedEvent migrates grouped unread count when group field changes`() = runTest { + val cid = "messaging:channel-id" + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(mapOf("a" to 1, "b" to 0)) + } + val newChannel = randomChannel( + id = "channel-id", + type = "messaging", + extraData = mapOf("group" to "b"), + ) + val handler = Fixture() + .withCurrentUser(currentUser) + .withMutableGlobalState(mutableGlobalState) + .withActiveChannel( + channelType = "messaging", + channelId = "channel-id", + extraData = mapOf("group" to "a"), + unreadMessages = 1, + ) + .get(this) + + handler.handleEvents(randomChannelUpdatedEvent(cid = cid, channel = newChannel)) + + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) + } + + @Test + fun `Two ChannelUpdatedEvents for same cid in one batch apply the migration only once`() = runTest { + val cid = "messaging:channel-id" + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(mapOf("a" to 2, "b" to 0)) + } + val newChannel = randomChannel( + id = "channel-id", + type = "messaging", + extraData = mapOf("group" to "b"), + ) + val handler = Fixture() + .withCurrentUser(currentUser) + .withMutableGlobalState(mutableGlobalState) + .withActiveChannel( + channelType = "messaging", + channelId = "channel-id", + extraData = mapOf("group" to "a"), + unreadMessages = 1, + ) + .get(this) + + handler.handleEvents( + randomChannelUpdatedEvent(cid = cid, channel = newChannel), + randomChannelUpdatedEvent(cid = cid, channel = newChannel), + ) + + // Without dedup the delta would be applied twice -> {"a" to 0, "b" to 2}. + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 1, "b" to 1) + } + + @Test + fun `NotificationRemovedFromChannelEvent before ChannelUpdatedEvent same cid suppresses migration`() = runTest { + val cid = "messaging:channel-id" + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(mapOf("a" to 1, "b" to 0)) + } + val newChannel = randomChannel( + id = "channel-id", + type = "messaging", + extraData = mapOf("group" to "b"), + ) + val handler = Fixture() + .withCurrentUser(currentUser) + .withMutableGlobalState(mutableGlobalState) + .withActiveChannel( + channelType = "messaging", + channelId = "channel-id", + extraData = mapOf("group" to "a"), + unreadMessages = 1, + ) + .get(this) + + handler.handleEvents( + randomNotificationRemovedFromChannelEvent(cid = cid, member = randomMember(user = currentUser)), + randomChannelUpdatedEvent(cid = cid, channel = newChannel), + ) + + // Removed-from-channel marks the cid as destroyed for this batch, so the subsequent + // channel.updated delta does not migrate counts. + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 1, "b" to 0) + } + + @Test + fun `NotificationMarkReadEvent before ChannelUpdatedEvent same cid does not double-apply delta`() = runTest { + val cid = "messaging:channel-id" + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(mapOf("a" to 1, "b" to 0)) + } + val newChannel = randomChannel( + id = "channel-id", + type = "messaging", + extraData = mapOf("group" to "b"), + ) + val handler = Fixture() + .withCurrentUser(currentUser) + .withMutableGlobalState(mutableGlobalState) + .withActiveChannel( + channelType = "messaging", + channelId = "channel-id", + extraData = mapOf("group" to "a"), + unreadMessages = 1, + ) + .get(this) + + // HGUC mark_read carries authoritative {a:0, b:0}; channel.updated must NOT then dec a/inc b + // on top (the channel is now read). + handler.handleEvents( + randomNotificationMarkReadEvent( + cid = cid, + user = currentUser, + groupedUnreadChannels = mapOf("a" to 0, "b" to 0), + ), + randomChannelUpdatedEvent(cid = cid, channel = newChannel), + ) + + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 0) + } + + @Test + fun `MarkAllReadEvent before ChannelUpdatedEvent suppresses migration`() = runTest { + val cid = "messaging:channel-id" + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(mapOf("a" to 1, "b" to 0)) + } + val newChannel = randomChannel( + id = "channel-id", + type = "messaging", + extraData = mapOf("group" to "b"), + ) + val handler = Fixture() + .withCurrentUser(currentUser) + .withMutableGlobalState(mutableGlobalState) + .withActiveChannel( + channelType = "messaging", + channelId = "channel-id", + extraData = mapOf("group" to "a"), + unreadMessages = 1, + ) + .get(this) + + handler.handleEvents( + randomMarkAllReadEvent(user = currentUser), + randomChannelUpdatedEvent(cid = cid, channel = newChannel), + ) + + // MarkAllRead conceptually clears unread on all channels; delta no-ops despite stale cache. + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 1, "b" to 0) + } + @ParameterizedTest @MethodSource("sharedLocationArguments") fun `GlobalState should be updated with shared locations`( @@ -407,6 +587,36 @@ internal class EventHandlerSequentialTest { fun listener(): ChatEventListener = capturedListener.get() + /** + * Stubs [stateRegistry] so the channel identified by [channelType] / [channelId] is + * active and `mutableChannel(...).toChannel()` returns a channel with the given + * [extraData] and a single [ChannelUserRead] for [currentUser] carrying [unreadMessages]. + */ + fun withActiveChannel( + channelType: String, + channelId: String, + extraData: Map, + unreadMessages: Int, + ) = apply { + val cached = randomChannel( + id = channelId, + type = channelType, + extraData = extraData, + read = listOf( + io.getstream.chat.android.randomChannelUserRead( + user = currentUser, + unreadMessages = unreadMessages, + ), + ), + ) + val channelMutableState: io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState = + mock { + on { toChannel() } doReturn cached + } + whenever(stateRegistry.isActiveChannel(channelType, channelId)) doReturn true + whenever(stateRegistry.mutableChannel(channelType, channelId)) doReturn channelMutableState + } + fun get(scope: CoroutineScope) = EventHandlerSequential( currentUserId = currentUser.id, subscribeForEvents = subscribeForEvents, @@ -438,6 +648,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..bb59961a96d 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 @@ -18,6 +18,9 @@ package io.getstream.chat.android.state.internal import app.cash.turbine.test import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.api.models.QueryChannelsResult +import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.errors.ChatErrorCode import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.ConnectedEvent @@ -29,8 +32,13 @@ import io.getstream.chat.android.client.sync.SyncState import io.getstream.chat.android.client.test.randomConnectedEvent 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.InternalStreamChatApi 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.GroupedChannelsGroupQuery +import io.getstream.chat.android.models.InFilterObject import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.TimeDuration @@ -45,8 +53,10 @@ 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.plugin.state.querychannels.GroupedQueryConfig import io.getstream.chat.android.state.sync.internal.SyncManager import io.getstream.chat.android.test.TestCall import io.getstream.chat.android.test.asCall @@ -69,6 +79,8 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestInstance import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -78,6 +90,8 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.util.Date +@Suppress("LargeClass") +@OptIn(InternalStreamChatApi::class) @ExperimentalCoroutinesApi @TestInstance(TestInstance.Lifecycle.PER_CLASS) internal class SyncManagerTest { @@ -540,6 +554,564 @@ 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) + on(it.groupedQueryConfig()) doReturn GroupedQueryConfig( + limit = null, + pageSize = 5, + watch = true, + presence = false, + ) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever( + chatClient.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) 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).queryGroupedChannelsInternal( + limit = null, + groups = mapOf("all" to GroupedChannelsGroupQuery(limit = 5)), + watch = true, + presence = false, + ) + 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) + on(it.groupedQueryConfig()) doReturn GroupedQueryConfig( + limit = null, + pageSize = null, + watch = true, + presence = false, + ) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever( + chatClient.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) 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) + on(it.groupedQueryConfig()) doReturn GroupedQueryConfig( + limit = null, + pageSize = null, + watch = true, + presence = false, + ) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever( + chatClient.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) 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) + on(it.groupedQueryConfig()) doReturn GroupedQueryConfig( + limit = null, + pageSize = null, + watch = true, + presence = false, + ) + } + + 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.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) 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() + } + + @Test + @Suppress("LongMethod") + fun `dual-mode reconnect updateActiveChannels should query only cids not covered by grouped or standard`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + // Three distinct cids: one from the grouped response, one from the standard response, + // one only present in the active channel states (must be the only cid queried). + val groupedChannel = randomChannel(type = "messaging", id = "grouped") + val standardChannel = randomChannel(type = "messaging", id = "standard") + val activeOnlyCid = "messaging:active" + + val groupedQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + on(it.groupedQueryConfig()) doReturn GroupedQueryConfig( + null, + null, + true, + false, + ) + } + val standardQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn null + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + onBlocking { it.queryFirstPage() } doReturn Result.Success(listOf(standardChannel)) + } + + val groupedActiveState: ChannelState = mock { + on(it.cid) doReturn groupedChannel.cid + on(it.recoveryNeeded) doReturn false + } + val standardActiveState: ChannelState = mock { + on(it.cid) doReturn standardChannel.cid + on(it.recoveryNeeded) doReturn false + } + val activeOnlyState: ChannelState = mock { + on(it.cid) doReturn activeOnlyCid + on(it.recoveryNeeded) doReturn false + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(groupedQuery, standardQuery) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(stateRegistry.getActiveChannelStates()) doReturn + listOf(groupedActiveState, standardActiveState, activeOnlyState) + whenever(stateRegistry.getTrackedWatchedChannels()) doReturn emptySet() + whenever( + chatClient.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) doReturn TestCall( + Result.Success( + GroupedChannels( + groups = mapOf( + "all" to GroupedChannelsGroup(groupKey = "all", channels = listOf(groupedChannel)), + ), + ), + ), + ) + whenever(chatClient.queryChannelsInternal(any())) doReturn TestCall( + Result.Success(QueryChannelsResult(channels = emptyList(), predefinedFilter = null)), + ) + 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) + + val captor = argumentCaptor() + verify(chatClient).queryChannelsInternal(captor.capture()) + val filter = captor.firstValue.filter as InFilterObject + assertEquals("cid", filter.fieldName) + assertEquals(setOf(activeOnlyCid), filter.values) + } + + @Test + @Suppress("LongMethod") + fun `dual-mode reconnect should exclude tracked watched cids from updateActiveChannels`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val watchedCid = "messaging:watched" + val activeOnlyCid = "messaging:active" + + val groupedQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + on(it.groupedQueryConfig()) doReturn GroupedQueryConfig( + limit = null, + pageSize = null, + watch = true, + presence = false, + ) + } + val standardQuery: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn null + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + onBlocking { it.queryFirstPage() } doReturn Result.Success(emptyList()) + } + + val watchedState: ChannelState = mock { + on(it.cid) doReturn watchedCid + on(it.recoveryNeeded) doReturn false + } + val activeOnlyState: ChannelState = mock { + on(it.cid) doReturn activeOnlyCid + on(it.recoveryNeeded) doReturn false + } + + val rewatchedLogic: ChannelLogic = mock { + onBlocking { it.watch(any(), any()) } doReturn Result.Success(randomChannel()) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(groupedQuery, standardQuery) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(logicRegistry.channel(any(), any())) doReturn rewatchedLogic + whenever(stateRegistry.getActiveChannelStates()) doReturn listOf(watchedState, activeOnlyState) + whenever(stateRegistry.getTrackedWatchedChannels()) doReturn setOf(watchedCid) + whenever( + chatClient.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) doReturn TestCall(Result.Success(GroupedChannels(groups = emptyMap()))) + whenever(chatClient.queryChannelsInternal(any())) doReturn TestCall( + Result.Success(QueryChannelsResult(channels = emptyList(), predefinedFilter = null)), + ) + 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) + + val captor = argumentCaptor() + verify(chatClient).queryChannelsInternal(captor.capture()) + val filter = captor.firstValue.filter as InFilterObject + assertEquals("cid", filter.fieldName) + assertEquals(setOf(activeOnlyCid), filter.values) + } + + @Test + fun `reconnect with no grouped or standard queries should query all active channel states`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val cidA = "messaging:a" + val cidB = "messaging:b" + val activeStateA: ChannelState = mock { + on(it.cid) doReturn cidA + on(it.recoveryNeeded) doReturn false + } + val activeStateB: ChannelState = mock { + on(it.cid) doReturn cidB + on(it.recoveryNeeded) doReturn false + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn emptyList() + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever(stateRegistry.getActiveChannelStates()) doReturn listOf(activeStateA, activeStateB) + whenever(chatClient.queryChannelsInternal(any())) doReturn TestCall( + Result.Success(QueryChannelsResult(channels = emptyList(), predefinedFilter = null)), + ) + 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) + + // No grouped path, no standard path → exclusion set is empty, all active cids are queried. + verify(chatClient, never()).queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ) + val captor = argumentCaptor() + verify(chatClient).queryChannelsInternal(captor.capture()) + val filter = captor.firstValue.filter as InFilterObject + assertEquals("cid", filter.fieldName) + assertEquals(setOf(cidA, cidB), filter.values) + } + + @Test + fun `on reconnect with multiple grouped queries should pass per-group limits and shared flags`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + val sharedConfigA = GroupedQueryConfig(limit = null, pageSize = 5, watch = true, presence = false) + val sharedConfigB = GroupedQueryConfig(limit = null, pageSize = 5, watch = true, presence = false) + val queryLogicA: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "a" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + on(it.groupedQueryConfig()) doReturn sharedConfigA + } + val queryLogicB: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "b" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + on(it.groupedQueryConfig()) doReturn sharedConfigB + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogicA, queryLogicB) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever( + chatClient.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) 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) + + verify(chatClient).queryGroupedChannelsInternal( + limit = null, + groups = mapOf( + "a" to GroupedChannelsGroupQuery(limit = 5), + "b" to GroupedChannelsGroupQuery(limit = 5), + ), + watch = true, + presence = false, + ) + } + + @Test + fun `on reconnect when no captured per-group pageSize should still include the group key with empty entry`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + // Caller had set a request-level limit but no per-group override → captured pageSize is null. + val queryLogic: QueryChannelsLogic = mock { + on(it.groupKey()) doReturn "all" + on(it.currentRequest()) doReturn mock() + on(it.recoveryNeeded()) doReturn MutableStateFlow(false) + on(it.groupedQueryConfig()) doReturn GroupedQueryConfig( + limit = 20, + pageSize = null, + watch = true, + presence = false, + ) + } + + whenever(logicRegistry.getActiveQueryChannelsLogic()) doReturn listOf(queryLogic) + whenever(logicRegistry.getActiveChannelsLogic()) doReturn emptyList() + whenever( + chatClient.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) 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) + + // The group key is kept in the map even with no per-group pageSize so the server + // still refreshes it. The empty `GroupedChannelsGroupQuery(limit = null)` serializes + // as `{}` and signals "include this group in the response". + verify(chatClient).queryGroupedChannelsInternal( + limit = 20, + groups = mapOf("all" to GroupedChannelsGroupQuery(limit = null)), + watch = true, + presence = false, + ) + } + + @Test + fun `on reconnect with no captured config should still include the group key with empty entry`() = + runTest(testDispatcher) { + val createdAt = localDate() + val rawCreatedAt = streamDateFormatter.format(createdAt) + + // No groupedQueryConfig stubbed → first response hasn't landed yet. + 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.queryGroupedChannelsInternal( + limit = anyOrNull(), + groups = anyOrNull(), + watch = any(), + presence = any(), + ), + ) 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) + + verify(chatClient).queryGroupedChannelsInternal( + limit = null, + groups = mapOf("all" to GroupedChannelsGroupQuery(limit = null)), + watch = true, + presence = false, + ) + } + + 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..ce33e458f5d --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/listener/internal/QueryGroupedChannelsListenerStateTest.kt @@ -0,0 +1,389 @@ +/* + * 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.event.handler.grouped.internal.GroupedUnreadChannelsUpdater +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.plugin.state.querychannels.GroupedQueryConfig +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 stateRegistry: StateRegistry = mock() + private val groupedUnreadChannelsUpdater = GroupedUnreadChannelsUpdater( + stateRegistry = stateRegistry, + currentUserId = "test-user", + ) + private val listener = QueryGroupedChannelsListenerState(logic, globalState, groupedUnreadChannelsUpdater) + + @Test + fun `successful first-page 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 - first-page request (no next/prev cursor) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery()), + 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 pagination result with next cursor does not update grouped unread counts`() = runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow( + mapOf("direct" to 4, "support" to 1), + ) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf( + "support" to GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 7, + next = null, + prev = null, + ), + ), + ), + ) + // when - paginated request (`next` cursor set on the requested group) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery(next = "cursor")), + watch = false, + presence = false, + ) + // then - pagination payload does not carry unread counts + verify(globalState, never()).setGroupedUnreadChannels(any()) + } + + @Test + fun `successful pagination result with prev cursor does not update grouped unread counts`() = runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow( + mapOf("direct" to 4, "support" to 1), + ) + val result = Result.Success( + value = GroupedChannels( + groups = mapOf( + "support" to GroupedChannelsGroup( + groupKey = "support", + channels = emptyList(), + unreadChannels = 7, + next = null, + prev = null, + ), + ), + ), + ) + // when - paginated request (`prev` cursor set on the requested group) + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery(prev = "cursor")), + watch = false, + presence = false, + ) + // then - pagination payload does not carry unread counts + verify(globalState, never()).setGroupedUnreadChannels(any()) + } + + @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 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 `onQueryGroupedChannelsRequest with explicit groups writes config per requested key`() = runTest { + // when + listener.onQueryGroupedChannelsRequest( + limit = 20, + groups = mapOf( + "a" to GroupedChannelsGroupQuery(limit = 5), + "b" to GroupedChannelsGroupQuery(), + ), + watch = true, + presence = false, + ) + // then - per-group override captured for "a", request-level only for "b" + verify(queryChannelsLogic).setGroupedQueryConfig( + GroupedQueryConfig(limit = 20, pageSize = 5, watch = true, presence = false), + ) + verify(queryChannelsLogic).setGroupedQueryConfig( + GroupedQueryConfig(limit = 20, pageSize = null, watch = true, presence = false), + ) + verify(logic).queryChannels(eq(QueryChannelsIdentifier.Grouped("a"))) + verify(logic).queryChannels(eq(QueryChannelsIdentifier.Grouped("b"))) + } + + @Test + fun `onQueryGroupedChannelsRequest with null groups writes nothing`() = runTest { + // when + listener.onQueryGroupedChannelsRequest( + limit = null, + groups = null, + watch = true, + presence = false, + ) + // then - no per-group keys to write to; defer to result-side capture + verify(queryChannelsLogic, never()).setGroupedQueryConfig(any()) + } + + @Test + fun `success writes captured config with per-group override to matching group and general limit to others`() = + runTest { + // given + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow(emptyMap()) + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val groupA = GroupedChannelsGroup(groupKey = "a", channels = emptyList()) + val groupB = GroupedChannelsGroup(groupKey = "b", channels = emptyList()) + val result = Result.Success( + value = GroupedChannels(groups = mapOf("a" to groupA, "b" to groupB)), + ) + // when - "a" has a per-group limit override; "b" only the request-level fallback + listener.onQueryGroupedChannelsResult( + result = result, + limit = 20, + groups = mapOf("a" to GroupedChannelsGroupQuery(limit = 5)), + watch = true, + presence = false, + ) + // then - per-group override captured for "a", request-level only for "b" + verify(queryChannelsLogic).setGroupedQueryConfig( + GroupedQueryConfig(limit = 20, pageSize = 5, watch = true, presence = false), + ) + verify(queryChannelsLogic).setGroupedQueryConfig( + GroupedQueryConfig(limit = 20, pageSize = null, watch = true, presence = false), + ) + } + + @Test + fun `success with all-null request still captures watch and presence flags`() = runTest { + // given - recovery-shaped call where the SDK relies solely on server defaults + whenever(globalState.groupedUnreadChannels) doReturn MutableStateFlow(emptyMap()) + doNothing().`when`(globalState).setGroupedUnreadChannels(any()) + val group = GroupedChannelsGroup(groupKey = "direct", channels = emptyList()) + val result = Result.Success(GroupedChannels(groups = mapOf("direct" to group))) + // when + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = null, + watch = true, + presence = true, + ) + // then - config still written so subsequent pagination knows the watch/presence flags + verify(queryChannelsLogic).setGroupedQueryConfig( + GroupedQueryConfig(limit = null, pageSize = null, watch = true, presence = 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) + } + + @Test + fun `success treats keys with requested prev 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 prev cursor for "support" + listener.onQueryGroupedChannelsResult( + result = result, + limit = null, + groups = mapOf("support" to GroupedChannelsGroupQuery(prev = "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/internal/LogicRegistryTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistryTest.kt index c16d759a4a1..6d24b2da478 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistryTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistryTest.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Thread import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter +import io.getstream.chat.android.state.event.handler.grouped.internal.GroupAwareChatEventHandlerFactory 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.plugin.state.querychannels.internal.QueryChannelsMutableState @@ -76,20 +77,13 @@ internal class LogicRegistryTest { coroutineScope = testCoroutines.scope // Stub query channels state. LogicRegistry resolves state via the identifier-based - // overload, so we stub that one. For Standard identifiers we project the filter/sort back - // out as the initial values for QueryChannelsMutableState. + // overload; the MutableState derives its initial filter/sort from the identifier itself. queryChannelsStateCache.clear() whenever(stateRegistry.queryChannels(any())).thenAnswer { val identifier = it.getArgument(0) - val (initialFilter, initialSort) = when (identifier) { - is QueryChannelsIdentifier.Standard -> identifier.filter to identifier.sort - is QueryChannelsIdentifier.Predefined -> Filters.neutral() to QuerySortByField() - } queryChannelsStateCache.getOrPut(identifier) { QueryChannelsMutableState( identifier = identifier, - initialFilter = initialFilter, - initialSort = initialSort, scope = coroutineScope, latestUsers = MutableStateFlow(emptyMap()), activeLiveLocations = MutableStateFlow(emptyList()), @@ -334,6 +328,40 @@ internal class LogicRegistryTest { Assertions.assertTrue(activeLogics.contains(queryThreadsLogic2)) } + // region queryChannels (grouped + identifier-keyed) + + @Test + fun `queryChannels with Grouped identifier creates a logic instance`() { + val identifier = QueryChannelsIdentifier.Grouped("vip") + + val logic = logicRegistry.queryChannels(identifier) + + Assertions.assertNotNull(logic) + } + + @Test + fun `queryChannels with Grouped identifier returns the same instance on repeat call`() { + val identifier = QueryChannelsIdentifier.Grouped("vip") + + val first = logicRegistry.queryChannels(identifier) + val second = logicRegistry.queryChannels(identifier) + + Assertions.assertSame(first, second) + } + + @Test + fun `queryChannels with Grouped identifier auto-installs a GroupAwareChatEventHandlerFactory`() { + val identifier = QueryChannelsIdentifier.Grouped("vip") + + logicRegistry.queryChannels(identifier) + + val state = queryChannelsStateCache[identifier] + Assertions.assertNotNull(state) + Assertions.assertTrue(state!!.chatEventHandlerFactory is GroupAwareChatEventHandlerFactory) + } + + // endregion + // -- QueryChannels -- @Test diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt new file mode 100644 index 00000000000..ba76e7db8f8 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt @@ -0,0 +1,378 @@ +/* + * 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.logic.querychannels.internal + +import io.getstream.chat.android.client.ChatClient +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.models.Channel +import io.getstream.chat.android.models.ChannelConfig +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.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +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.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +internal class QueryChannelsLogicGroupedTest { + + @get:Rule + val testCoroutines = TestCoroutineRule() + + private lateinit var client: ChatClient + private lateinit var queryChannelsStateLogic: QueryChannelsStateLogic + private lateinit var queryChannelsDatabaseLogic: QueryChannelsDatabaseLogic + private lateinit var queryChannelsState: QueryChannelsState + private lateinit var queryChannelsSpec: QueryChannelsSpec + private lateinit var logic: QueryChannelsLogic + + @BeforeEach + fun setUp() { + client = mock() + queryChannelsStateLogic = mock() + queryChannelsDatabaseLogic = mock() + queryChannelsState = mock() + queryChannelsSpec = QueryChannelsSpec( + filter = Filters.neutral(), + querySort = QuerySortByField.descByName("last_updated"), + groupKey = GROUP_KEY, + ) + + whenever(queryChannelsStateLogic.getState()) doReturn queryChannelsState + whenever(queryChannelsState.recoveryNeeded) doReturn MutableStateFlow(false) + whenever(queryChannelsState.currentRequest) doReturn MutableStateFlow(null) + whenever(queryChannelsStateLogic.getQuerySpecs()) doReturn queryChannelsSpec + + logic = QueryChannelsLogic( + identifier = QueryChannelsIdentifier.Grouped(GROUP_KEY), + client = client, + queryChannelsStateLogic = queryChannelsStateLogic, + queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, + ) + } + + // region applyGroupedResult + + @Test + fun `applyGroupedResult on first page when state is empty adds channels without removing`() = runTest { + // Given + val channels = listOf(randomChannel(id = "ch1"), randomChannel(id = "ch2")) + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = channels, + next = null, + prev = null, + ) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then + verify(queryChannelsStateLogic, never()).removeChannels(any()) + verify(queryChannelsStateLogic).setCids(emptySet()) + verify(queryChannelsStateLogic).addChannelsState(channels) + } + + @Test + fun `applyGroupedResult on first page when state has existing channels replaces them`() = runTest { + // Given + val existing = mapOf("messaging:old1" to randomChannel(id = "old1")) + val newChannels = listOf(randomChannel(id = "new1")) + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = newChannels, + next = null, + prev = null, + ) + whenever(queryChannelsStateLogic.getChannels()) doReturn existing + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then + verify(queryChannelsStateLogic).removeChannels(existing.keys) + verify(queryChannelsStateLogic).setCids(emptySet()) + verify(queryChannelsStateLogic).addChannelsState(newChannels) + } + + @Test + fun `applyGroupedResult on subsequent page appends without removing existing`() = runTest { + // Given + val existing = mapOf("messaging:old1" to randomChannel(id = "old1")) + val newChannels = listOf(randomChannel(id = "new1")) + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = newChannels, + next = "cursor-next", + prev = null, + ) + whenever(queryChannelsStateLogic.getChannels()) doReturn existing + + // When + logic.applyGroupedResult(group, isFirstPage = false) + + // Then — neither removeChannels nor setCids(emptySet()) should fire on a paginated page. + verify(queryChannelsStateLogic, never()).removeChannels(any()) + verify(queryChannelsStateLogic, never()).setCids(any()) + verify(queryChannelsStateLogic, never()).setChannelsOffset(any()) + verify(queryChannelsStateLogic).addChannelsState(newChannels) + } + + @Test + fun `applyGroupedResult on first page resets channelsOffset`() = runTest { + // Given + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = emptyList(), + next = null, + prev = null, + ) + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then — defensive reset so a Standard offset paginator can't pick up stale state. + verify(queryChannelsStateLogic).setChannelsOffset(0) + } + + @Test + fun `applyGroupedResult stores group next cursor`() = runTest { + // Given + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = emptyList(), + next = "cursor-xyz", + prev = null, + ) + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then + verify(queryChannelsStateLogic).setNextCursor("cursor-xyz") + } + + @Test + fun `applyGroupedResult sets endOfChannels to true when next cursor is null`() = runTest { + // Given + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = emptyList(), + next = null, + prev = null, + ) + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then + verify(queryChannelsStateLogic).setEndOfChannels(true) + } + + @Test + fun `applyGroupedResult sets endOfChannels to false when next cursor is non-null`() = runTest { + // Given + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = emptyList(), + next = "cursor-next", + prev = null, + ) + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then + verify(queryChannelsStateLogic).setEndOfChannels(false) + } + + @Test + fun `applyGroupedResult resets loading and recovery flags`() = runTest { + // Given + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = emptyList(), + next = null, + prev = null, + ) + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + verify(queryChannelsStateLogic).setLoadingMore(false) + verify(queryChannelsStateLogic).setRecoveryNeeded(false) + } + + @Test + fun `applyGroupedResult persists spec, configs, and channels to database`() = runTest { + // Given + val channels = listOf(randomChannel(id = "ch1"), randomChannel(id = "ch2")) + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = channels, + next = null, + prev = null, + ) + + // When + logic.applyGroupedResult(group, isFirstPage = true) + + // Then + verify(queryChannelsDatabaseLogic).insertQueryChannels(queryChannelsSpec) + val expectedConfigs = channels.map { ChannelConfig(it.type, it.config) } + verify(queryChannelsDatabaseLogic).insertChannelConfigs(expectedConfigs) + verify(queryChannelsDatabaseLogic).storeStateForChannels(channels.toSet()) + } + + @Test + fun `applyGroupedResult is a no-op on non-Grouped identifiers`() = runTest { + // Given — a logic constructed with a Standard identifier. + val standardLogic = QueryChannelsLogic( + identifier = QueryChannelsIdentifier.Standard( + filter = Filters.eq("type", "messaging"), + sort = QuerySortByField.descByName("last_updated"), + ), + client = client, + queryChannelsStateLogic = queryChannelsStateLogic, + queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, + ) + val group = GroupedChannelsGroup( + groupKey = GROUP_KEY, + channels = listOf(randomChannel()), + ) + + // When + standardLogic.applyGroupedResult(group, isFirstPage = true) + + // Then — no state mutations on a non-Grouped logic. + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic, never()).setNextCursor(any()) + verify(queryChannelsStateLogic, never()).setEndOfChannels(any()) + verify(queryChannelsDatabaseLogic, never()).insertQueryChannels(any()) + } + + // endregion + + // region loadOfflineGroupedChannels + + @Test + fun `loadOfflineGroupedChannels populates state from cache when state is empty`() = runTest { + // Given + val cachedChannels = listOf(randomChannel(id = "ch1"), randomChannel(id = "ch2")) + whenever(queryChannelsStateLogic.getChannels()) doReturn null + whenever( + queryChannelsDatabaseLogic.fetchChannelsFromCache( + any(), + any(), + ), + ) doReturn CachedQueryChannels(spec = queryChannelsSpec, channels = cachedChannels) + + // When + logic.loadOfflineGroupedChannels() + + // Then + verify(queryChannelsStateLogic).addChannelsState(cachedChannels) + verify(queryChannelsStateLogic).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + } + + @Test + fun `loadOfflineGroupedChannels skips state population when a concurrent apply already populated state`() = runTest { + // Given — applyGroupedResult landed during the DB read and populated the state first. + val existingChannels = mapOf("messaging:ch1" to randomChannel(id = "ch1")) + val cachedChannels = listOf(randomChannel(id = "stale1")) + whenever(queryChannelsStateLogic.getChannels()) doReturn existingChannels + whenever( + queryChannelsDatabaseLogic.fetchChannelsFromCache( + any(), + any(), + ), + ) doReturn CachedQueryChannels(spec = queryChannelsSpec, channels = cachedChannels) + + // When + logic.loadOfflineGroupedChannels() + + // Then — stale cache must NOT overwrite fresh data, but housekeeping setters still fire. + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + } + + @Test + fun `loadOfflineGroupedChannels handles null cache gracefully`() = runTest { + // Given — no spec found in DB (first time the group is queried on this device). + whenever(queryChannelsStateLogic.getChannels()) doReturn null + whenever( + queryChannelsDatabaseLogic.fetchChannelsFromCache( + any(), + any(), + ), + ) doReturn null + + // When + logic.loadOfflineGroupedChannels() + + // Then — no channels to add, but state is initialized and loading flag reset. + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic).setLoadingFirstPage(false) + } + + @Test + fun `loadOfflineGroupedChannels is a no-op on non-Grouped identifiers`() = runTest { + // Given — a logic constructed with a Standard identifier. + val standardLogic = QueryChannelsLogic( + identifier = QueryChannelsIdentifier.Standard( + filter = Filters.eq("type", "messaging"), + sort = QuerySortByField.descByName("last_updated"), + ), + client = client, + queryChannelsStateLogic = queryChannelsStateLogic, + queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, + ) + + // When + standardLogic.loadOfflineGroupedChannels() + + // Then — Standard path's offline read goes through loadOfflineChannels(request), not this. + verify(queryChannelsDatabaseLogic, never()).fetchChannelsFromCache(any(), any()) + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + verify(queryChannelsStateLogic, never()).initializeChannelsIfNeeded() + verify(queryChannelsStateLogic, never()).setLoadingFirstPage(any()) + } + + // endregion + + private companion object { + private const val GROUP_KEY = "test-group" + } +} 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 44ad641afec..65b78d318e4 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 @@ -30,6 +30,7 @@ import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.test.TestCoroutineRule import io.getstream.chat.android.test.asCall @@ -480,4 +481,116 @@ 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 + CachedQueryChannels(spec = queryChannelsSpec, channels = 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 + CachedQueryChannels(spec = queryChannelsSpec, channels = 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 Grouped accessors + + @Test + fun `groupKey returns null for a Standard identifier`() { + val standardLogic = QueryChannelsLogic( + identifier = QueryChannelsIdentifier.Standard(filter, sort), + client = client, + queryChannelsStateLogic = queryChannelsStateLogic, + queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, + ) + assertEquals(null, standardLogic.groupKey()) + } + + @Test + fun `groupKey returns the identifier's groupKey for a Grouped identifier`() { + val groupedLogic = QueryChannelsLogic( + identifier = QueryChannelsIdentifier.Grouped(groupKey = "direct"), + client = client, + queryChannelsStateLogic = queryChannelsStateLogic, + queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, + ) + assertEquals("direct", groupedLogic.groupKey()) + } + + @Test + fun `groupedQueryConfig forwards to the state logic`() { + val config = GroupedQueryConfig(limit = 30, pageSize = 10, watch = true, presence = false) + whenever(queryChannelsStateLogic.getGroupedQueryConfig()) doReturn config + + assertEquals(config, logic.groupedQueryConfig()) + verify(queryChannelsStateLogic).getGroupedQueryConfig() + } + + @Test + fun `setGroupedQueryConfig forwards to the state logic`() { + val config = GroupedQueryConfig(limit = 30, pageSize = 10, watch = false, presence = false) + + logic.setGroupedQueryConfig(config) + + verify(queryChannelsStateLogic).setGroupedQueryConfig(config) + } + + @Test + fun `currentRequest reads the active request from the state`() { + val request = QueryChannelsRequest(filter = filter, limit = 30, querySort = sort) + whenever(queryChannelsState.currentRequest) doReturn MutableStateFlow(request) + + assertEquals(request, logic.currentRequest()) + } + + // 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 f780a768095..f72a593c6bf 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,25 +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.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 @@ -50,11 +58,8 @@ internal class QueryChannelsStateLogicTest { private val id = randomString() private val testCid = (type to id).toCid() - private val queryChannelsSpec = QueryChannelsSpec( - filter = Filters.neutral(), - querySort = QuerySortByField.descByName(""), - cids = setOf(testCid), - ) + private val queryChannelsSpec = + QueryChannelsSpec(Filters.neutral(), QuerySortByField.descByName("")).also { it.cids = setOf(testCid) } private val mutableState: QueryChannelsMutableState = mock { on(it.rawChannels) doReturn emptyMap() @@ -158,4 +163,181 @@ 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(""), + ).also { it.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..c2aa3137f30 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 @@ -16,6 +16,7 @@ package io.getstream.chat.android.state.plugin.state +import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Location @@ -24,13 +25,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.WatchedChannelStateFlow 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 +253,41 @@ internal class StateRegistryTest { // Then assertSame(state1, state2) } + + // region WatchedChannelStateFlow tracking + + @Test + fun `trackWatchedChannel should make CID available in getTrackedWatchedChannels`() { + val flow = WatchedChannelStateFlow(MutableStateFlow(null), "messaging:123") + stateRegistry.trackWatchedChannel(flow) + + 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 flows`() { + val flow1 = WatchedChannelStateFlow(MutableStateFlow(null), "messaging:123") + val flow2 = WatchedChannelStateFlow(MutableStateFlow(null), "messaging:123") + stateRegistry.trackWatchedChannel(flow1) + stateRegistry.trackWatchedChannel(flow2) + + assertEquals(setOf("messaging:123"), stateRegistry.getTrackedWatchedChannels()) + } + + @Test + fun `clear should remove all tracked entries`() { + val flow = WatchedChannelStateFlow(MutableStateFlow(null), "messaging:123") + stateRegistry.trackWatchedChannel(flow) + + 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 index c70e1567cd0..6408943654e 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/querychannels/internal/QueryChannelsMutableStateTest.kt @@ -16,39 +16,254 @@ package io.getstream.chat.android.state.plugin.state.querychannels.internal +import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.Location +import io.getstream.chat.android.models.User 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.test.TestCoroutineExtension +import io.getstream.chat.android.randomUser +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.GroupedQueryConfig +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 org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.mock +import java.util.Date +@OptIn(ExperimentalCoroutinesApi::class) internal class QueryChannelsMutableStateTest { - companion object { - @JvmField - @RegisterExtension - val testCoroutines = TestCoroutineExtension() - } + @get:Rule + val testCoroutines = TestCoroutineRule() + + private val filter = Filters.eq("type", "messaging") + private val sort = QuerySortByField.descByName("last_message_at") + private val identifier = QueryChannelsIdentifier.Standard(filter, sort) + private val latestUsers = MutableStateFlow>(emptyMap()) + private val activeLiveLocations = MutableStateFlow>(emptyList()) - private val initialFilter = Filters.eq("type", "messaging") - private val initialSort = QuerySortByField.descByName("last_message_at") + private lateinit var state: QueryChannelsMutableState - private fun newState( - identifier: QueryChannelsIdentifier = QueryChannelsIdentifier.Standard(initialFilter, initialSort), - ) = QueryChannelsMutableState( + private fun newState(identifier: QueryChannelsIdentifier) = QueryChannelsMutableState( identifier = identifier, - initialFilter = initialFilter, - initialSort = initialSort, scope = testCoroutines.scope, - latestUsers = MutableStateFlow(emptyMap()), - activeLiveLocations = MutableStateFlow(emptyList()), + latestUsers = latestUsers, + activeLiveLocations = activeLiveLocations, ) + @BeforeEach + fun setUp() { + state = newState(identifier) + } + + // 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 + + // region applyResolvedSpec (Predefined) + private val predefinedIdentifier = QueryChannelsIdentifier.Predefined( name = "predefined", filterValues = null, @@ -57,68 +272,62 @@ internal class QueryChannelsMutableStateTest { @Test fun `applyResolvedSpec updates filter and sort accessors`() { - val state = newState(identifier = predefinedIdentifier) + val predefinedState = newState(predefinedIdentifier) val newFilter = Filters.eq("type", "team") val newSort = QuerySortByField.ascByName("name") - state.applyResolvedSpec(newFilter, newSort) + predefinedState.applyResolvedSpec(newFilter, newSort) - assertEquals(newFilter, state.filter) - assertEquals(newSort, state.sort) + assertEquals(newFilter, predefinedState.filter) + assertEquals(newSort, predefinedState.sort) } @Test fun `applyResolvedSpec updates the in-memory queryChannelsSpec`() { - val state = newState(identifier = predefinedIdentifier) + val predefinedState = newState(predefinedIdentifier) val newFilter = Filters.eq("type", "team") val newSort = QuerySortByField.ascByName("name") - state.applyResolvedSpec(newFilter, newSort) + predefinedState.applyResolvedSpec(newFilter, newSort) - assertEquals(newFilter, state.queryChannelsSpec.filter) - assertEquals(newSort, state.queryChannelsSpec.querySort) + assertEquals(newFilter, predefinedState.queryChannelsSpec.filter) + assertEquals(newSort, predefinedState.queryChannelsSpec.querySort) } @Test - fun `applyResolvedSpec re-sorts the channels flow with the new comparator`() { - // Given a predefined-identifier state seeded with channels sorted descending by name. - val descByName = QuerySortByField.descByName("name") - val descState = QueryChannelsMutableState( - identifier = predefinedIdentifier, - initialFilter = initialFilter, - initialSort = descByName, - scope = testCoroutines.scope, - latestUsers = MutableStateFlow(emptyMap()), - activeLiveLocations = MutableStateFlow(emptyList()), - ) + fun `applyResolvedSpec re-sorts the channels flow with the new comparator`() = runTest { + // Given a predefined-identifier state seeded with channels. + val predefinedState = newState(predefinedIdentifier) val a = randomChannel(id = "a", type = "messaging", name = "alpha") val b = randomChannel(id = "b", type = "messaging", name = "bravo") val c = randomChannel(id = "c", type = "messaging", name = "charlie") - descState.setChannels(mapOf(a.cid to a, b.cid to b, c.cid to c)) + // Apply descending name sort first. + predefinedState.applyResolvedSpec(filter, QuerySortByField.descByName("name")) + predefinedState.setChannels(mapOf(a.cid to a, b.cid to b, c.cid to c)) + advanceUntilIdle() - val sortedDesc = descState.channels.value!!.map { it.name } + val sortedDesc = predefinedState.channels.value!!.map { it.name } assertEquals(listOf("charlie", "bravo", "alpha"), sortedDesc) // When the resolved sort flips to ascending, channels re-emit in the new order. - val ascByName = QuerySortByField.ascByName("name") - descState.applyResolvedSpec(initialFilter, ascByName) + predefinedState.applyResolvedSpec(filter, QuerySortByField.ascByName("name")) + advanceUntilIdle() - val sortedAsc = descState.channels.value!!.map { it.name } + val sortedAsc = predefinedState.channels.value!!.map { it.name } assertEquals(listOf("alpha", "bravo", "charlie"), sortedAsc) } @Test fun `applyResolvedSpec is a no-op for Standard identifier`() { - val state = newState(identifier = QueryChannelsIdentifier.Standard(initialFilter, initialSort)) val newFilter = Filters.eq("type", "team") val newSort = QuerySortByField.ascByName("name") state.applyResolvedSpec(newFilter, newSort) - assertEquals(initialFilter, state.filter) - assertEquals(initialSort, state.sort) - assertEquals(initialFilter, state.queryChannelsSpec.filter) - assertEquals(initialSort, state.queryChannelsSpec.querySort) + assertEquals(filter, state.filter) + assertEquals(sort, state.sort) + assertEquals(filter, state.queryChannelsSpec.filter) + assertEquals(sort, state.queryChannelsSpec.querySort) } @Test @@ -129,10 +338,61 @@ internal class QueryChannelsMutableStateTest { sortValues = mapOf("b" to 2), ) - val state = newState(identifier = identifier) + val predefinedState = newState(identifier) - assertEquals("p", state.queryChannelsSpec.predefinedFilterName) - assertEquals(mapOf("a" to 1), state.queryChannelsSpec.predefinedFilterValues) - assertEquals(mapOf("b" to 2), state.queryChannelsSpec.predefinedSortValues) + assertEquals("p", predefinedState.queryChannelsSpec.predefinedFilterName) + assertEquals(mapOf("a" to 1), predefinedState.queryChannelsSpec.predefinedFilterValues) + assertEquals(mapOf("b" to 2), predefinedState.queryChannelsSpec.predefinedSortValues) } + + // endregion + + // region Grouped setters + + @Test + fun `setNextCursor updates nextCursor flow`() = runTest { + assertNull(state.nextCursor.value) + state.setNextCursor("cursor-123") + assertEquals("cursor-123", state.nextCursor.value) + state.setNextCursor(null) + assertNull(state.nextCursor.value) + } + + @Test + fun `setGroupedQueryConfig updates groupedQueryConfig flow`() = runTest { + assertNull(state.groupedQueryConfig.value) + val config = GroupedQueryConfig(limit = 30, pageSize = 10, watch = true, presence = false) + state.setGroupedQueryConfig(config) + assertEquals(config, state.groupedQueryConfig.value) + } + + @Test + fun `setCids updates cids on the in-memory spec`() { + val cids = setOf(randomCID(), randomCID()) + state.setCids(cids) + assertEquals(cids, state.queryChannelsSpec.cids) + } + + @Test + fun `grouped identifier wires groupKey and default sort into the spec`() { + val groupedState = newState(QueryChannelsIdentifier.Grouped(groupKey = "direct")) + + assertEquals("direct", groupedState.queryChannelsSpec.groupKey) + // Default sort for Grouped is descending by "last_updated". + assertEquals(QuerySortByField.descByName("last_updated"), groupedState.sort) + } + + // endregion + + // region chatEventHandlerFactory + + @Test + fun `setting chatEventHandlerFactory to null clears the wired handler`() { + state.chatEventHandlerFactory = ChatEventHandlerFactory(clientState = mock()) + state.chatEventHandlerFactory = null + + assertNull(state.chatEventHandlerFactory) + } + + // endregion }