From dd2a76cd9d81a227e84e1a008a5acd08b8d4a7f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 16 Oct 2026 09:19:07 +0100 Subject: [PATCH 1/4] Update grouped channel lists on partial updates to unwatched channels Parse notification.channel_partial_updated with the generated models. The event's channel is merged into the local channel (state, offline storage and grouped unread counts) instead of replacing it. Grouped lists remove a channel that no longer matches its tab, and watch a channel that now matches when it can show within the loaded channels. A watched channel that is hidden is not added. --- .../chat/android/client/test/Mother.kt | 17 ++ .../api/stream-chat-android-client.api | 23 ++ .../client/api2/mapping/DomainMapping.kt | 24 +++ .../client/api2/mapping/EventMapping.kt | 22 ++ .../android/client/channel/ChannelClient.kt | 2 + .../chat/android/client/events/ChatEvent.kt | 19 ++ .../internal/GroupAwareChatEventHandler.kt | 40 ++++ .../internal/GroupedUnreadChannelsUpdater.kt | 11 + .../internal/EventHandlerSequential.kt | 12 ++ .../internal/ChannelEventHandlerImpl.kt | 5 + .../legacy/ChannelEventHandlerLegacyImpl.kt | 5 + .../internal/QueryChannelsLogic.kt | 26 ++- .../utils/internal/ChannelPartialUpdate.kt | 63 ++++++ .../android/client/parser2/MoshiChatParser.kt | 2 + .../ChannelPartialUpdateResponseAdapter.kt | 46 ++++ .../client/parser2/adapters/EventAdapter.kt | 3 + .../models/ChannelPartialUpdateResponse.kt | 59 +++++ .../NotificationChannelPartialUpdatedEvent.kt | 78 +++++++ .../android/client/EventChatJsonProvider.kt | 24 +++ .../client/api2/mapping/DomainMappingTest.kt | 37 ++++ .../api2/mapping/EventMappingTestArguments.kt | 44 ++++ .../GroupAwareChatEventHandlerTest.kt | 204 ++++++++++++++++++ .../GroupedUnreadChannelsUpdaterTest.kt | 23 ++ ...EventHandlerSequentialPartialUpdateTest.kt | 108 ++++++++++ .../internal/ChannelEventHandlerImplTest.kt | 18 ++ .../ChannelEventHandlerLegacyImplTest.kt | 20 ++ .../QueryChannelsLogicGroupedWatchTest.kt | 190 ++++++++++++++++ .../internal/ChannelPartialUpdateTest.kt | 102 +++++++++ .../android/client/parser/EventArguments.kt | 23 ++ .../GeneratedNotificationEventParsingTest.kt | 3 + .../api/stream-chat-android-core.api | 1 + .../chat/android/models/EventType.kt | 1 + 32 files changed, 1253 insertions(+), 2 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdate.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelPartialUpdateResponseAdapter.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelPartialUpdateResponse.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/NotificationChannelPartialUpdatedEvent.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdateTest.kt diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index 5f36f8aa5131..b20fd92dab4a 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 @@ -39,6 +39,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent @@ -628,6 +629,22 @@ public fun randomNewMessageEvent( ) } +public fun randomNotificationChannelPartialUpdatedEvent( + createdAt: Date = randomDate(), + cid: String = randomCID(), + channelType: String = randomString(), + channelId: String = randomString(), + channel: Channel = randomChannel(), +): NotificationChannelPartialUpdatedEvent = NotificationChannelPartialUpdatedEvent( + type = EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED, + createdAt = createdAt, + rawCreatedAt = streamFormatter.format(createdAt), + cid = cid, + channelType = channelType, + channelId = channelId, + channel = channel, +) + public fun randomNotificationChannelTruncatedEvent( createdAt: Date = randomDate(), 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 c4dcde41996c..2eb093152018 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -2227,6 +2227,29 @@ 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/NotificationChannelPartialUpdatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;)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 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Lio/getstream/chat/android/models/Channel; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/client/events/NotificationChannelPartialUpdatedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelPartialUpdatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelPartialUpdatedEvent; + 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 getRawCreatedAt ()Ljava/lang/String; + public fun getType ()Ljava/lang/String; + public fun hashCode ()I + 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/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 diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 5de3587b5acb..b6bf9c909c3a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -96,6 +96,7 @@ import io.getstream.chat.android.network.models.ChannelConfigWithInfo import io.getstream.chat.android.network.models.ChannelMemberPartialResponse import io.getstream.chat.android.network.models.ChannelMemberResponse import io.getstream.chat.android.network.models.ChannelOwnCapability +import io.getstream.chat.android.network.models.ChannelPartialUpdateResponse import io.getstream.chat.android.network.models.ChannelPushPreferencesResponse import io.getstream.chat.android.network.models.ChannelResponse import io.getstream.chat.android.network.models.ChannelStateResponse @@ -309,6 +310,29 @@ internal class DomainMapping( ).syncUnreadCountWithReads(currentUserIdProvider()) .let(channelTransformer::transform) + /** + * Transforms [ChannelPartialUpdateResponse] into a [Channel] holding only the fields it carries. Like for a full + * channel, `name` and `image` come from the custom data, `disabled` is also kept in [Channel.extraData] and the + * channel transformer is applied, so the fields it carries replace local values that were transformed too. + */ + internal fun ChannelPartialUpdateResponse.toDomain(): Channel = + Channel( + id = id, + type = type, + name = custom["name"] as? String ?: "", + image = custom["image"] as? String ?: "", + frozen = frozen, + disabled = disabled, + createdAt = createdAt, + updatedAt = updatedAt, + team = team.orEmpty(), + extraData = custom.withDeclared("disabled" to disabled) + .mapNotNull { (key, value) -> value?.let { key to it } } + .toMap() + .minus(listOf("name", "image")) + .toMutableMap(), + ).let(channelTransformer::transform) + internal fun ChannelResponse.toChannelInfo(): ChannelInfo = ChannelInfo( cid = cid, 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 b33f9f9783a3..6dc5c9b6b7d9 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 @@ -60,6 +60,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -125,6 +126,7 @@ import io.getstream.chat.android.network.models.MessageUpdatedEvent as Generated import io.getstream.chat.android.network.models.NotificationAddedToChannelEvent as GeneratedNotificationAddedToChannelEvent import io.getstream.chat.android.network.models.NotificationChannelDeletedEvent as GeneratedNotificationChannelDeletedEvent import io.getstream.chat.android.network.models.NotificationChannelMutesUpdatedEvent as GeneratedNotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.network.models.NotificationChannelPartialUpdatedEvent as GeneratedNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.network.models.NotificationChannelTruncatedEvent as GeneratedNotificationChannelTruncatedEvent import io.getstream.chat.android.network.models.NotificationInviteAcceptedEvent as GeneratedNotificationInviteAcceptedEvent import io.getstream.chat.android.network.models.NotificationInviteRejectedEvent as GeneratedNotificationInviteRejectedEvent @@ -469,6 +471,7 @@ internal class EventMapping( is GeneratedNotificationInviteAcceptedEvent -> toDomain() is GeneratedNotificationInviteRejectedEvent -> toDomain() is GeneratedNotificationChannelDeletedEvent -> toDomain() + is GeneratedNotificationChannelPartialUpdatedEvent -> toDomain() is GeneratedNotificationChannelTruncatedEvent -> toDomain() is GeneratedNotificationThreadMessageNewEvent -> toDomain() is GeneratedNotificationMarkUnreadEvent -> toDomain() @@ -937,6 +940,25 @@ internal class EventMapping( ) } + /** + * Transforms the generated [GeneratedNotificationChannelPartialUpdatedEvent] to + * [NotificationChannelPartialUpdatedEvent]. + */ + private fun GeneratedNotificationChannelPartialUpdatedEvent.toDomain(): NotificationChannelPartialUpdatedEvent = + with(domainMapping) { + val cid = requireNotNull(cid) + val (channelType, channelId) = cid.cidToTypeAndId() + return NotificationChannelPartialUpdatedEvent( + type = type, + createdAt = createdAt.date, + rawCreatedAt = createdAt.raw, + cid = cid, + channelType = channelType, + channelId = channelId, + channel = channel.toDomain(), + ) + } + /** * Transforms the generated [GeneratedNotificationChannelTruncatedEvent] to [NotificationChannelTruncatedEvent]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt index a260ab8a6e86..3b64a1b366a4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt @@ -59,6 +59,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -278,6 +279,7 @@ public class ChannelClient internal constructor( is NewMessageEvent -> event.cid == cid is NotificationAddedToChannelEvent -> event.cid == cid is NotificationChannelDeletedEvent -> event.cid == cid + is NotificationChannelPartialUpdatedEvent -> event.cid == cid is NotificationChannelTruncatedEvent -> event.cid == cid is NotificationInviteAcceptedEvent -> event.cid == cid is NotificationInviteRejectedEvent -> event.cid == cid 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 15c9cc15521d..3d2a9704a269 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 @@ -421,6 +421,25 @@ public data class NotificationChannelMutesUpdatedEvent( override val me: User, ) : ChatEvent(), HasOwnUser +/** + * Triggered when a channel is partially updated, for members of the channel who are not watching it. Members who + * are watching it receive [ChannelUpdatedEvent] instead. + * + * The [channel] carries only part of the channel data: [Channel.id], [Channel.type], [Channel.name], [Channel.image], + * [Channel.frozen], [Channel.disabled], [Channel.team], [Channel.createdAt], [Channel.updatedAt] and + * [Channel.extraData] (the complete custom data, plus the `disabled` flag). Other fields, such as members, + * messages, reads, config and [Channel.lastMessageAt], keep their default values. + */ +public data class NotificationChannelPartialUpdatedEvent( + override val type: String, + override val createdAt: Date, + override val rawCreatedAt: String, + override val cid: String, + override val channelType: String, + override val channelId: String, + override val channel: Channel, +) : CidEvent(), HasChannel + /** * Triggered when a channels' history is truncated */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index 9b731fd69e73..57e9f9e2f3f5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -26,7 +26,9 @@ import io.getstream.chat.android.client.events.CidEvent import io.getstream.chat.android.client.events.HasChannel import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationMessageNewEvent +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt import io.getstream.chat.android.models.Channel @@ -65,6 +67,8 @@ internal class GroupAwareChatEventHandler( is ChannelUpdatedByUserEvent, -> routeByGroup((event as HasChannel).channel, cachedChannel, filter) + is NotificationChannelPartialUpdatedEvent -> routePartialUpdate(event, cachedChannel, filter) + is NotificationAddedToChannelEvent, is NotificationMessageNewEvent, -> watchAndAddIfBelongsHere(event.cid, event.channel, filter) @@ -120,10 +124,46 @@ internal class GroupAwareChatEventHandler( private fun watchAndAddIfBelongsHere(cid: String, channel: Channel, filter: FilterObject): EventHandlingResult = if (channelBelongsHere(channel, filter)) EventHandlingResult.WatchAndAdd(cid) else EventHandlingResult.Skip + /** + * Same checks as [routeByGroup], against [cachedChannel] with the partial update applied. The event's channel + * lacks data such as members and the last message time, so a channel that is not in the list yet is watched to + * get its full data instead of being added from the event. + * + * Without [cachedChannel], only the group is checked: the last activity is unknown, so the cutoff is left to the + * list, which checks the watched channel before showing it. The event is only sent to members, and a hidden + * channel is dropped after the watch. The query skips the watch when the channel sorts below its loaded pages. + */ + private fun routePartialUpdate( + event: NotificationChannelPartialUpdatedEvent, + cachedChannel: Channel?, + filter: FilterObject, + ): EventHandlingResult { + val belongsHere = if (cachedChannel == null) { + groupMatches(event.channel, filter) + } else { + channelBelongsHere(cachedChannel.mergePartialUpdate(event.channel), filter) && + isCurrentUserMember(cachedChannel) && + cachedChannel.hidden != true + } + val isInList = channels.value?.containsKey(event.cid) == true + return when { + belongsHere && !isInList -> EventHandlingResult.WatchAndAdd(event.cid) + !belongsHere && isInList -> EventHandlingResult.Remove(event.cid) + else -> EventHandlingResult.Skip + } + } + private fun channelBelongsHere(channel: Channel, filter: FilterObject): Boolean = resolver.resolve(channel).contains(groupKey) && filter.matchesGroupedChannel(group = channel.group, queryTimestamp = channel.queryTimestamp, now = now()) + /** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown. */ + private fun groupMatches(channel: Channel, filter: FilterObject): Boolean { + val checkedAt = now() + return resolver.resolve(channel).contains(groupKey) && + filter.matchesGroupedChannel(group = channel.group, queryTimestamp = checkedAt, now = checkedAt) + } + /** The channel with [newMessage] counted in its last message time, as the channel state counts it. */ private fun Channel.withNewMessage(newMessage: Message?): Channel = when (newMessage) { null -> this diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt index f5121d792666..e66323018257 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt @@ -23,6 +23,7 @@ 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.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent @@ -105,6 +106,16 @@ internal class GroupedUnreadChannelsUpdater( event: ChannelUpdatedByUserEvent, ): Map = applyDelta(current, batchId, event.cid, event.channel) + /** + * `notification.channel_partial_updated` delta. Same semantics as the [ChannelUpdatedEvent] overload. The + * event's channel carries the custom data, so it holds the new group. + */ + fun calculateUpdatedCounts( + current: Map, + batchId: Int, + event: NotificationChannelPartialUpdatedEvent, + ): 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. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index b1cfb79ac68b..3fd2884981f0 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -56,6 +56,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -112,6 +113,7 @@ import io.getstream.chat.android.client.internal.state.plugin.logic.channel.inte import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry import io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt @@ -457,6 +459,10 @@ internal class EventHandlerSequential( groupedUnreadChannels = groupedUnreadChannelsUpdater .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) } + (event as? NotificationChannelPartialUpdatedEvent)?.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) @@ -818,6 +824,12 @@ internal class EventHandlerSequential( ?.mergeChannelFromEvent(event.channel) ?: event.channel batch.addChannel(channel) } + is NotificationChannelPartialUpdatedEvent -> { + // The event's channel is incomplete, so it is never stored on its own. + batch.getCurrentChannel(event.cid) + ?.mergePartialUpdate(event.channel) + ?.let(batch::addChannel) + } is ChannelDeletedEvent -> { val channel = batch.getCurrentChannel(event.cid) ?.mergeChannelFromEvent(event.channel) ?: event.channel diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt index 814a169a5d2a..5f23ae867768 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImpl.kt @@ -39,6 +39,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -72,6 +73,7 @@ import io.getstream.chat.android.client.extensions.internal.toMessageReminderInf import io.getstream.chat.android.client.internal.state.event.handler.internal.utils.toChannelUserRead import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.client.utils.message.isPinned import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageReminder @@ -297,6 +299,9 @@ internal class ChannelEventHandlerImpl( // Channel update events is ChannelUpdatedEvent -> state.updateChannelData(event) is ChannelUpdatedByUserEvent -> state.updateChannelData(event) + is NotificationChannelPartialUpdatedEvent -> state.updateChannelData { data -> + data?.mergePartialUpdate(event.channel) + } is ChannelHiddenEvent -> { state.setHidden(true) if (event.clearHistory) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt index bc12f0f32bb8..48b45de80f5f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt @@ -39,6 +39,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -71,6 +72,7 @@ import io.getstream.chat.android.client.extensions.internal.toMessageReminderInf import io.getstream.chat.android.client.internal.state.event.handler.internal.utils.toChannelUserRead import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelEventHandler import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateLegacyImpl +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageReminder @@ -215,6 +217,9 @@ internal class ChannelEventHandlerLegacyImpl( // Channel update events is ChannelUpdatedEvent -> stateLogic.updateChannelData(event) is ChannelUpdatedByUserEvent -> stateLogic.updateChannelData(event) + is NotificationChannelPartialUpdatedEvent -> stateLogic.updateChannelData { data -> + data?.mergePartialUpdate(event.channel) + } is ChannelHiddenEvent -> { stateLogic.setHidden(true) if (event.clearHistory) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index e63f161342a2..fd0ffd150d7b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -24,11 +24,13 @@ import io.getstream.chat.android.client.api.state.ChannelsStateData import io.getstream.chat.android.client.api.state.querychannels.GroupedQueryConfig import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.CidEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.internal.state.event.handler.grouped.internal.isUnreadInGroupFor import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelConfig @@ -289,7 +291,7 @@ internal class QueryChannelsLogic( } /** - * Calls watch channel and adds result to the query. + * Calls watch channel and adds result to the query. A grouped query does not add the channel when it is hidden. * * @param cid cid of the channel. */ @@ -297,6 +299,8 @@ internal class QueryChannelsLogic( val result = client.channel(cid = cid).watch().await() if (result is Result.Success) { + // A partial channel update can reach a member who hid the channel. + if (identifier is QueryChannelsIdentifier.Grouped && result.value.hidden == true) return addChannel(result.value) } } @@ -666,10 +670,28 @@ internal class QueryChannelsLogic( return chatEvents.map { event -> val channel = (event as? CidEvent)?.let { resolvedChannels[it.cid] } - queryChannelsStateLogic.handleChatEvent(event, channel) + val result = queryChannelsStateLogic.handleChatEvent(event, channel) + val isPartialUpdateBelowLoadedChannels = event is NotificationChannelPartialUpdatedEvent && + result is EventHandlingResult.WatchAndAdd && + !isWithinLoadedChannels(channel?.mergePartialUpdate(event.channel) ?: event.channel) + if (isPartialUpdateBelowLoadedChannels) EventHandlingResult.Skip else result } } + /** + * Whether [channel] can be shown in a grouped list without the pages before it: the group has no more pages, or + * the channel sorts at or above the last loaded channel. A channel below them is shown when its page loads, so a + * partial update on an old channel does not watch it. Without local data the channel has no last message time, + * so its position is a lower bound. + */ + private fun isWithinLoadedChannels(channel: Channel): Boolean { + if (identifier !is QueryChannelsIdentifier.Grouped) return true + val state = queryChannelsStateLogic.getState() + if (state.endOfChannels.value) return true + val lastLoaded = state.channels.value?.lastOrNull() ?: return true + return state.sort.comparator.compare(channel, lastLoaded) <= 0 + } + /** * Refreshes a single channel. * @see [refreshChannelsState] diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdate.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdate.kt new file mode 100644 index 000000000000..6966530fb6b8 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdate.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.internal.state.utils.internal + +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.client.parser2.adapters.GENERATED_CHANNEL_EXTRA_DATA_KEYS +import io.getstream.chat.android.client.parser2.adapters.LEGACY_CHANNEL_EXTRA_DATA_KEYS +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelData + +/** + * Channel response fields that are also kept in the extra data next to the custom fields. A partial update carries + * only the custom fields and `disabled`, so the local values of the others are kept. + */ +private val serverFieldsInExtraData = LEGACY_CHANNEL_EXTRA_DATA_KEYS + GENERATED_CHANNEL_EXTRA_DATA_KEYS + +/** + * Applies the fields carried by [NotificationChannelPartialUpdatedEvent.channel] to this channel and keeps all + * the others, such as members, reads and [Channel.lastMessageAt]. + */ +internal fun Channel.mergePartialUpdate(partial: Channel): Channel = copy( + name = partial.name, + image = partial.image, + frozen = partial.frozen, + disabled = partial.disabled, + team = partial.team, + createdAt = partial.createdAt, + updatedAt = partial.updatedAt, + extraData = mergePartialExtraData(current = extraData, partial = partial.extraData), +) + +/** + * Applies the fields carried by [NotificationChannelPartialUpdatedEvent.channel] to this channel data and keeps + * all the others. + */ +internal fun ChannelData.mergePartialUpdate(partial: Channel): ChannelData = copy( + name = partial.name, + image = partial.image, + frozen = partial.frozen, + disabled = partial.disabled, + team = partial.team, + createdAt = partial.createdAt, + updatedAt = partial.updatedAt, + extraData = mergePartialExtraData(current = extraData, partial = partial.extraData), +) + +// The partial update holds the complete custom data, so a custom field it lacks was removed. +private fun mergePartialExtraData(current: Map, partial: Map): Map = + current.filterKeys(serverFieldsInExtraData::contains) + partial diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt index 80b363f8b19c..fa87dc9ce253 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt @@ -33,6 +33,7 @@ import io.getstream.chat.android.client.parser.ChatParser import io.getstream.chat.android.client.parser2.adapters.ChannelMemberPartialResponseAdapter import io.getstream.chat.android.client.parser2.adapters.ChannelMemberRequestAdapter import io.getstream.chat.android.client.parser2.adapters.ChannelMemberResponseAdapter +import io.getstream.chat.android.client.parser2.adapters.ChannelPartialUpdateResponseAdapter import io.getstream.chat.android.client.parser2.adapters.ChannelResponseAdapter import io.getstream.chat.android.client.parser2.adapters.CreatePollOptionRequestAdapter import io.getstream.chat.android.client.parser2.adapters.CreatePollRequestAdapter @@ -99,6 +100,7 @@ internal class MoshiChatParser( .add(ChannelResponseAdapter) .add(ChannelMemberResponseAdapter) .add(ChannelMemberPartialResponseAdapter) + .add(ChannelPartialUpdateResponseAdapter) .add(FlagRequestAdapterFactory) .add(ThreadStateResponseAdapter) .add(ThreadResponseAdapter) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelPartialUpdateResponseAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelPartialUpdateResponseAdapter.kt new file mode 100644 index 000000000000..ad2be630a54b --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/ChannelPartialUpdateResponseAdapter.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.parser2.adapters + +import com.squareup.moshi.FromJson +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.ToJson +import io.getstream.chat.android.network.models.ChannelPartialUpdateResponse + +// Downstream (read-only) adapter for the channel of a partial update: the channel custom fields arrive at the +// object root, so they are collected into `custom`, which is that model's @Json name for them. +internal object ChannelPartialUpdateResponseAdapter : + CustomObjectDtoAdapter( + ChannelPartialUpdateResponse::class, + extraDataPropertyName = "custom", + mergesNestedExtraData = true, + ) { + + @FromJson + fun fromJson( + jsonReader: JsonReader, + mapAdapter: JsonAdapter>, + channelAdapter: JsonAdapter, + ): ChannelPartialUpdateResponse? = parseWithExtraData(jsonReader, mapAdapter, channelAdapter) + + @ToJson + @Suppress("UNUSED_PARAMETER") + fun toJson(jsonWriter: JsonWriter, value: ChannelPartialUpdateResponse): Unit = + error("Can't convert this to Json") +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt index a3d635ffbbef..599e5d2e85ea 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt @@ -52,6 +52,7 @@ import io.getstream.chat.android.network.models.MessageUpdatedEvent import io.getstream.chat.android.network.models.NotificationAddedToChannelEvent import io.getstream.chat.android.network.models.NotificationChannelDeletedEvent import io.getstream.chat.android.network.models.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.network.models.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.network.models.NotificationChannelTruncatedEvent import io.getstream.chat.android.network.models.NotificationInviteAcceptedEvent import io.getstream.chat.android.network.models.NotificationInviteRejectedEvent @@ -199,6 +200,8 @@ internal fun generatedEventAdapters(moshi: Moshi): Map(moshi) { mapOf("cid" to cid) }, + EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED to + generatedEventAdapter(moshi) { mapOf("cid" to cid) }, EventType.USER_PRESENCE_CHANGED to generatedEventAdapter(moshi) { emptyMap() }, EventType.USER_UPDATED to generatedEventAdapter(moshi) { emptyMap() }, EventType.USER_DELETED to generatedEventAdapter(moshi) { emptyMap() }, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelPartialUpdateResponse.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelPartialUpdateResponse.kt new file mode 100644 index 000000000000..dbf14acabe51 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/ChannelPartialUpdateResponse.kt @@ -0,0 +1,59 @@ +/* + * 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. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * Basic channel data and the complete custom data after a partial update. + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class ChannelPartialUpdateResponse( + @Json(name = "cid") + internal val cid: String, + + @Json(name = "created_at") + internal val createdAt: java.util.Date, + + @Json(name = "disabled") + internal val disabled: Boolean, + + @Json(name = "frozen") + internal val frozen: Boolean, + + @Json(name = "id") + internal val id: String, + + @Json(name = "type") + internal val type: String, + + @Json(name = "updated_at") + internal val updatedAt: java.util.Date, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "team") + internal val team: String? = null, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/NotificationChannelPartialUpdatedEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/NotificationChannelPartialUpdatedEvent.kt new file mode 100644 index 000000000000..bf207e94ad3d --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/network/models/NotificationChannelPartialUpdatedEvent.kt @@ -0,0 +1,78 @@ +/* + * 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. + */ + +@file:Suppress( + "ArrayInDataClass", + "EnumEntryName", + "RemoveRedundantQualifierName", + "UnusedImport", +) + +package io.getstream.chat.android.network.models + +import com.squareup.moshi.Json + +/** + * Sent to channel members who are not watching after a partial update. Watching connections receive channel.updated instead. + */ +@com.squareup.moshi.JsonClass(generateAdapter = true) +internal data class NotificationChannelPartialUpdatedEvent( + @Json(name = "created_at") + internal val createdAt: io.getstream.chat.android.network.infrastructure.ExactDate, + + @Json(name = "channel") + internal val channel: io.getstream.chat.android.network.models.ChannelPartialUpdateResponse, + + @Json(name = "custom") + internal val custom: Map = emptyMap(), + + @Json(name = "type") + internal val type: String = "notification.channel_partial_updated", + + @Json(name = "channel_id") + internal val channelId: String? = null, + + @Json(name = "channel_member_count") + internal val channelMemberCount: Int? = null, + + @Json(name = "channel_message_count") + internal val channelMessageCount: Int? = null, + + @Json(name = "channel_type") + internal val channelType: String? = null, + + @Json(name = "cid") + internal val cid: String? = null, + + @Json(name = "received_at") + internal val receivedAt: java.util.Date? = null, + + @Json(name = "team") + internal val team: String? = null, + + @Json(name = "channel_custom") + internal val channelCustom: Map? = null, +) : + io.getstream.chat.android.network.models.WSClientEvent, io.getstream.chat.android.network.models.WSEvent { + + override fun getWSClientEventType(): String { + return type + } + + override fun getWSEventType(): String { + return type + } +} 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 411a6c33e3d6..d585d371955d 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 @@ -266,6 +266,30 @@ internal fun createNotificationChannelDeletedEventStringJson() = """.trimIndent(), ) +internal fun createNotificationChannelPartialUpdatedEventStringJson() = + createChatEventStringJson( + "notification.channel_partial_updated", + """ + "channel_type": "channelType", + "channel_id": "channelId", + "cid": "channelType:channelId", + "team": "team1", + "channel": { + "id": "channelId", + "type": "channelType", + "cid": "channelType:channelId", + "created_at": "2020-06-29T06:14:28.000Z", + "updated_at": "2020-06-29T06:14:28.000Z", + "frozen": true, + "disabled": false, + "team": "team1", + "name": "Channel name", + "image": "https://example.com/channel.png", + "group": "current" + } + """.trimIndent(), + ) + internal fun createNotificationChannelTruncatedEventStringJson() = createChatEventStringJson( "notification.channel_truncated", diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 0dc73719b9cf..bebc65aa6c22 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -124,6 +124,7 @@ import io.getstream.chat.android.network.models.Action import io.getstream.chat.android.network.models.ChannelConfigWithInfo import io.getstream.chat.android.network.models.ChannelMemberResponse import io.getstream.chat.android.network.models.ChannelOwnCapability +import io.getstream.chat.android.network.models.ChannelPartialUpdateResponse import io.getstream.chat.android.network.models.ChannelPushPreferencesResponse import io.getstream.chat.android.network.models.ChannelResponse import io.getstream.chat.android.network.models.ChatPreferencesResponse @@ -684,6 +685,42 @@ internal class DomainMappingTest { assertEquals(transformedChannel, result) } + @Test + fun `Partially updated channel should be transformed after it is mapped`() { + val channelTransformer = ChannelTransformer { it.copy(name = "${it.name} (transformed)") } + + val sut = Fixture() + .withChannelTransformer(channelTransformer) + .get() + + val result = with(sut) { channelPartialUpdateResponse().toDomain() } + + assertEquals("Channel name (transformed)", result.name) + } + + @Test + fun `ChannelPartialUpdateResponse promotes name and image out of custom and keeps disabled in extraData`() { + val sut = Fixture().get() + + val result = with(sut) { channelPartialUpdateResponse().toDomain() } + + assertEquals("Channel name", result.name) + assertEquals("image.png", result.image) + assertEquals(true, result.disabled) + assertEquals(mapOf("group" to "current", "disabled" to true), result.extraData) + } + + private fun channelPartialUpdateResponse() = ChannelPartialUpdateResponse( + cid = "messaging:123", + createdAt = Date(), + disabled = true, + frozen = false, + id = "123", + type = "messaging", + updatedAt = Date(), + custom = mapOf("name" to "Channel name", "image" to "image.png", "group" to "current"), + ) + @Test fun `ChannelResponse promotes name and image out of custom and keeps the rest as extraData`() { val channelResponse = randomChannelResponse( 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 6ec4fd84bed5..2deb7c261631 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 @@ -58,6 +58,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -97,6 +98,7 @@ import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.NoOpChannelTransformer import io.getstream.chat.android.models.NoOpMessageTransformer import io.getstream.chat.android.models.NoOpUserTransformer +import io.getstream.chat.android.network.models.ChannelPartialUpdateResponse import io.getstream.chat.android.positiveRandomInt import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomString @@ -127,6 +129,7 @@ import io.getstream.chat.android.network.models.MessageUpdatedEvent as Generated import io.getstream.chat.android.network.models.NotificationAddedToChannelEvent as GeneratedNotificationAddedToChannelEvent import io.getstream.chat.android.network.models.NotificationChannelDeletedEvent as GeneratedNotificationChannelDeletedEvent import io.getstream.chat.android.network.models.NotificationChannelMutesUpdatedEvent as GeneratedNotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.network.models.NotificationChannelPartialUpdatedEvent as GeneratedNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.network.models.NotificationChannelTruncatedEvent as GeneratedNotificationChannelTruncatedEvent import io.getstream.chat.android.network.models.NotificationInviteAcceptedEvent as GeneratedNotificationInviteAcceptedEvent import io.getstream.chat.android.network.models.NotificationInviteRejectedEvent as GeneratedNotificationInviteRejectedEvent @@ -561,6 +564,25 @@ internal object EventMappingTestArguments { private val notificationChannelMutesUpdatesDto = GeneratedEventDto(notificationChannelMutesUpdatesEvent) + private val notificationChannelPartialUpdatedEvent = GeneratedNotificationChannelPartialUpdatedEvent( + type = EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED, + createdAt = GENERATED_EXACT_DATE, + cid = CID, + channel = ChannelPartialUpdateResponse( + cid = CID, + createdAt = DATE, + disabled = true, + frozen = true, + id = CHANNEL_ID, + type = CHANNEL_TYPE, + updatedAt = DATE, + custom = mapOf("name" to "Channel name", "image" to "image.png", "group" to "current"), + team = "team1", + ), + ) + + private val notificationChannelPartialUpdatedDto = GeneratedEventDto(notificationChannelPartialUpdatedEvent) + private val notificationChannelTruncatedEvent = GeneratedNotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, createdAt = GENERATED_EXACT_DATE, @@ -1315,6 +1337,27 @@ internal object EventMappingTestArguments { me = with(domainMapping) { OWN_USER.toDomain() }, ) + private val notificationChannelPartialUpdated = NotificationChannelPartialUpdatedEvent( + type = notificationChannelPartialUpdatedEvent.type, + createdAt = notificationChannelPartialUpdatedEvent.createdAt.date, + rawCreatedAt = notificationChannelPartialUpdatedEvent.createdAt.raw, + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + channel = Channel( + id = CHANNEL_ID, + type = CHANNEL_TYPE, + name = "Channel name", + image = "image.png", + frozen = true, + disabled = true, + createdAt = DATE, + updatedAt = DATE, + team = "team1", + extraData = mapOf("group" to "current", "disabled" to true), + ), + ) + private val notificationChannelTruncated = NotificationChannelTruncatedEvent( type = notificationChannelTruncatedEvent.type, createdAt = notificationChannelTruncatedEvent.createdAt.date, @@ -1781,6 +1824,7 @@ internal object EventMappingTestArguments { Arguments.of(notificationAddedToChannelDto, notificationAddedToChannel), Arguments.of(notificationChannelDeletedDto, notificationChannelDeleted), Arguments.of(notificationChannelMutesUpdatesDto, notificationChannelMutesUpdates), + Arguments.of(notificationChannelPartialUpdatedDto, notificationChannelPartialUpdated), Arguments.of(notificationChannelTruncatedDto, notificationChannelTruncated), Arguments.of(notificationInviteAcceptedDto, notificationInviteAccepted), Arguments.of(notificationInviteRejectedDto, notificationInviteRejected), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index 63591cd72cce..c768b70ace4f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.client.test.randomMemberAddedEvent import io.getstream.chat.android.client.test.randomMemberRemovedEvent import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters @@ -507,6 +508,209 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.Skip, result) } + @Test + fun `Given a channel in the list When a partial update moves it to another group Should remove`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "other")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a channel in the list When a partial update removes its group Should remove`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = emptyMap()) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a channel in the list When a partial update keeps it in the group Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a channel not in local state When a partial update moves it into the group Should watch and add`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a cached channel not in the list When a partial update moves it into the group Should watch and add`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "other")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update keeps it in another group Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "other")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given the user hid the channel When a partial update moves it into the group Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "other")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = true) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a cached channel the user left When a partial update moves it into the group Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val cachedChannel = channel.copy(membership = null, hidden = false) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a recent last message in local state When a partial update sets an old touched_at Should keep it`() { + val currentUser = randomUser() + val channel = randomChannel( + createdAt = daysAgo(30), + lastMessageAt = daysAgo(1), + extraData = mapOf("group" to "vip"), + ) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip", "touched_at" to daysAgo(20))) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a channel in a filtered group When a partial update leaves it past the cutoff Should remove`() { + val currentUser = randomUser() + val channel = randomChannel( + createdAt = daysAgo(30), + lastMessageAt = daysAgo(20), + extraData = mapOf("group" to "vip", "touched_at" to daysAgo(1)), + ) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip", "touched_at" to daysAgo(20))) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update touches it within the cutoff Should watch and add`() { + val channel = randomChannel(createdAt = daysAgo(30), extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip", "touched_at" to daysAgo(1))) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update moves it into a filtered group Should watch and add`() { + // The last message time is unknown without local data, so the list checks the cutoff after the watch. + val channel = randomChannel(createdAt = daysAgo(30), extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update moves it into a group the filter excludes Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "archived")) + val filter = Filters.and(Filters.ne("group", "archived"), cutoff14Days) + + val result = handler.handleChatEvent(event, filter, cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + /** The event as the backend sends it: only the channel's basic fields and its custom data. */ + private fun partialUpdateOf(channel: Channel, custom: Map) = + randomNotificationChannelPartialUpdatedEvent( + cid = channel.cid, + channelType = channel.type, + channelId = channel.id, + channel = Channel( + id = channel.id, + type = channel.type, + createdAt = channel.createdAt, + updatedAt = channel.updatedAt, + extraData = custom + ("disabled" to false), + ), + ) + private fun channelMessage(createdAt: Date) = randomMessage( type = "regular", createdAt = createdAt, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt index ab1322a504f5..b3113a6ae15c 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt @@ -24,6 +24,7 @@ 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.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent @@ -202,6 +203,17 @@ internal class GroupedUnreadChannelsUpdaterTest { assertEquals(mapOf("a" to 2, "b" to 2), next) } + @Test + fun `NotificationChannelPartialUpdatedEvent applies the same migration semantics`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 2) + val current = mapOf("a" to 3, "b" to 1) + val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) + + assertEquals(mapOf("a" to 2, "b" to 2), next) + } + // endregion // region Same-batch dedup @@ -509,6 +521,17 @@ internal class GroupedUnreadChannelsUpdaterTest { message = null, ) + private fun partialUpdatedEvent(partialChannel: Channel): NotificationChannelPartialUpdatedEvent = + NotificationChannelPartialUpdatedEvent( + type = "notification.channel_partial_updated", + createdAt = Date(), + rawCreatedAt = "", + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + channel = partialChannel, + ) + private fun hasGroupedUnreadChannels(map: Map?): HasGroupedUnreadChannels = notificationMarkReadEvent(map = map) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt new file mode 100644 index 000000000000..623c4de820a8 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.event.handler.internal + +import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomUser +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.`should be equal to` +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.verify +import org.mockito.kotlin.whenever + +/** `notification.channel_partial_updated` through the event handler: grouped unread counts and offline storage. */ +internal class EventHandlerSequentialPartialUpdateTest { + + private val currentUser = randomUser() + + @Test + fun `NotificationChannelPartialUpdatedEvent 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 partialChannel = Channel(id = "channel-id", type = "messaging", extraData = mapOf("group" to "b")) + val handler = EventHandlerSequentialTest.Fixture() + .withCurrentUser(currentUser) + .withMutableGlobalState(mutableGlobalState) + .withActiveChannel( + channelType = "messaging", + channelId = "channel-id", + extraData = mapOf("group" to "a"), + unreadMessages = 1, + ) + .get(this) + + handler.handleEvents(randomNotificationChannelPartialUpdatedEvent(cid = cid, channel = partialChannel)) + + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) + } + + @Test + fun `When handling NotificationChannelPartialUpdatedEvent, The stored channel should get the partial update`() = + runTest { + val storedChannel = randomChannel(members = listOf(randomMember()), extraData = mapOf("group" to "a")) + val partialChannel = Channel( + id = storedChannel.id, + type = storedChannel.type, + extraData = mapOf("group" to "b"), + ) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn listOf(storedChannel) + whenever(repos.selectThreads(any())) doReturn emptyList() + val handler = EventHandlerSequentialTest.Fixture() + .withRepositoryFacade(repos) + .get(this) + + handler.handleEvents( + randomNotificationChannelPartialUpdatedEvent(cid = storedChannel.cid, channel = partialChannel), + ) + + val inserted = argumentCaptor>() + verify(repos).insertChannels(inserted.capture()) + val storedResult = inserted.firstValue.single() + storedResult.members `should be equal to` storedChannel.members + storedResult.extraData `should be equal to` mapOf("group" to "b") + } + + @Test + fun `When handling NotificationChannelPartialUpdatedEvent, A channel that is not stored should not be inserted`() = + runTest { + val event = randomNotificationChannelPartialUpdatedEvent() + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn emptyList() + whenever(repos.selectThreads(any())) doReturn emptyList() + val handler = EventHandlerSequentialTest.Fixture() + .withRepositoryFacade(repos) + .get(this) + + handler.handleEvents(event) + + verify(repos).insertChannels(emptyList()) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt index 433456928921..a4907ff2897c 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.client.extensions.internal.toMessageReminderInf import io.getstream.chat.android.client.internal.state.event.handler.internal.utils.toChannelUserRead import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.client.test.randomAnswerCastedEvent import io.getstream.chat.android.client.test.randomChannelDeletedEvent import io.getstream.chat.android.client.test.randomChannelHiddenEvent @@ -53,6 +54,7 @@ import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent import io.getstream.chat.android.client.test.randomNotificationChannelDeletedEvent import io.getstream.chat.android.client.test.randomNotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationChannelTruncatedEvent import io.getstream.chat.android.client.test.randomNotificationMarkReadEvent import io.getstream.chat.android.client.test.randomNotificationMarkUnreadEvent @@ -72,10 +74,13 @@ import io.getstream.chat.android.client.test.randomUserStartWatchingEvent import io.getstream.chat.android.client.test.randomVoteCastedEvent import io.getstream.chat.android.client.test.randomVoteChangedEvent import io.getstream.chat.android.client.test.randomVoteRemovedEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelData import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.TypingUser +import io.getstream.chat.android.models.toChannelData import io.getstream.chat.android.positiveRandomInt import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel @@ -852,6 +857,19 @@ internal class ChannelEventHandlerImplTest { verify(state).updateChannelData(event) } + @Test + fun `When NotificationChannelPartialUpdatedEvent is handled, Then the partial update is merged into channel data`() { + val currentData = randomChannel().toChannelData() + val partial = Channel(name = randomString(), extraData = mapOf("group" to randomString())) + val event = randomNotificationChannelPartialUpdatedEvent(cid = cid, channel = partial) + + handler.handle(event) + + val update = argumentCaptor<(ChannelData?) -> ChannelData?>() + verify(state).updateChannelData(update.capture()) + assertEquals(currentData.mergePartialUpdate(partial), update.firstValue(currentData)) + } + @Test fun `When ChannelUpdatedByUserEvent is handled, Then channel data is updated`() { val event = randomChannelUpdatedByUserEvent(cid = cid) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt index a94bc982fd55..331644ba1d9d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt @@ -34,6 +34,7 @@ import io.getstream.chat.android.client.extensions.internal.processPoll import io.getstream.chat.android.client.extensions.internal.toMessageReminderInfo import io.getstream.chat.android.client.internal.state.event.handler.internal.utils.toChannelUserRead import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateLegacyImpl +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.client.test.randomAnswerCastedEvent import io.getstream.chat.android.client.test.randomChannelDeletedEvent import io.getstream.chat.android.client.test.randomChannelHiddenEvent @@ -51,6 +52,7 @@ import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent import io.getstream.chat.android.client.test.randomNotificationChannelDeletedEvent import io.getstream.chat.android.client.test.randomNotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationChannelTruncatedEvent import io.getstream.chat.android.client.test.randomNotificationMarkReadEvent import io.getstream.chat.android.client.test.randomNotificationMarkUnreadEvent @@ -70,7 +72,10 @@ import io.getstream.chat.android.client.test.randomUserStartWatchingEvent import io.getstream.chat.android.client.test.randomVoteCastedEvent import io.getstream.chat.android.client.test.randomVoteChangedEvent import io.getstream.chat.android.client.test.randomVoteRemovedEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelData import io.getstream.chat.android.models.EventType +import io.getstream.chat.android.models.toChannelData import io.getstream.chat.android.positiveRandomInt import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel @@ -86,10 +91,12 @@ import io.getstream.chat.android.randomString import io.getstream.chat.android.randomThreadInfo import io.getstream.chat.android.randomUser import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -559,6 +566,19 @@ internal class ChannelEventHandlerLegacyImplTest { verify(stateLogic).updateChannelData(event) } + @Test + fun `When NotificationChannelPartialUpdatedEvent is handled, Then the partial update is merged into channel data`() { + val currentData = randomChannel().toChannelData() + val partial = Channel(name = randomString(), extraData = mapOf("group" to randomString())) + val event = randomNotificationChannelPartialUpdatedEvent(cid = cid, channel = partial) + + handler.handle(event) + + val update = argumentCaptor<(ChannelData?) -> ChannelData?>() + verify(stateLogic).updateChannelData(update.capture()) + assertEquals(currentData.mergePartialUpdate(partial), update.firstValue(currentData)) + } + @Test fun `When ChannelUpdatedByUserEvent is handled, Then channel data is updated`() { val event = randomChannelUpdatedByUserEvent(cid = cid) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt new file mode 100644 index 000000000000..b4cba4218fce --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.event.EventHandlingResult +import io.getstream.chat.android.client.api.state.QueryChannelsState +import io.getstream.chat.android.client.channel.ChannelClient +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomString +import io.getstream.chat.android.test.TestCoroutineRule +import io.getstream.chat.android.test.asCall +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +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 +import java.util.Date +import java.util.concurrent.TimeUnit + +internal class QueryChannelsLogicGroupedWatchTest { + + @get:Rule + val testCoroutines = TestCoroutineRule() + + private val client: ChatClient = mock() + private val queryChannelsStateLogic: QueryChannelsStateLogic = mock() + private val queryChannelsDatabaseLogic: QueryChannelsDatabaseLogic = mock() + private val queryChannelsState: QueryChannelsState = mock() + private lateinit var logic: QueryChannelsLogic + + @BeforeEach + fun setUp() { + whenever(queryChannelsStateLogic.getState()) doReturn queryChannelsState + whenever(queryChannelsState.recoveryNeeded) doReturn MutableStateFlow(false) + whenever(queryChannelsState.currentRequest) doReturn MutableStateFlow(null) + logic = QueryChannelsLogic( + identifier = QueryChannelsIdentifier.Grouped(GROUP_KEY), + client = client, + queryChannelsStateLogic = queryChannelsStateLogic, + queryChannelsDatabaseLogic = queryChannelsDatabaseLogic, + ) + } + + @Test + fun `a partial update watches a channel that sorts within the loaded channels`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + val event = partialUpdate(createdAt = daysAgo(1)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) + } + + @Test + fun `a partial update skips a channel that sorts below the loaded channels`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + val event = partialUpdate(createdAt = daysAgo(30)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.Skip), results) + } + + @Test + fun `a partial update watches a channel below the loaded channels when the group has no more pages`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = true) + val event = partialUpdate(createdAt = daysAgo(30)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) + } + + @Test + fun `a partial update places a stored channel by its last message`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + val event = partialUpdate(createdAt = daysAgo(30)) + val stored = randomChannel( + id = event.channelId, + type = event.channelType, + createdAt = daysAgo(30), + lastMessageAt = daysAgo(1), + ) + whenever(queryChannelsDatabaseLogic.selectChannels(listOf(event.cid))) doReturn listOf(stored) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) + } + + @Test + fun `other events still watch a channel that sorts below the loaded channels`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + val event = randomNotificationMessageNewEvent(channel = randomChannel(createdAt = daysAgo(30))) + whenever(queryChannelsStateLogic.handleChatEvent(any(), anyOrNull())) doReturn + EventHandlingResult.WatchAndAdd(event.cid) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) + } + + private suspend fun givenLoadedChannels(lastLoadedAt: Date, endOfChannels: Boolean) { + val loaded = listOf( + randomChannel(createdAt = lastLoadedAt, lastMessageAt = null), + randomChannel(createdAt = Date(lastLoadedAt.time + 1_000), lastMessageAt = null), + ).sortedWith(DEFAULT_SORT.comparator) + whenever(queryChannelsState.channels) doReturn MutableStateFlow(loaded) + whenever(queryChannelsState.endOfChannels) doReturn MutableStateFlow(endOfChannels) + whenever(queryChannelsState.sort) doReturn DEFAULT_SORT + whenever(queryChannelsDatabaseLogic.selectChannels(any())) doReturn emptyList() + } + + /** A partial update the group's handler resolved to a watch, carrying only the slim channel. */ + private fun partialUpdate(createdAt: Date): NotificationChannelPartialUpdatedEvent { + val slim = Channel(id = randomString(), type = "messaging", createdAt = createdAt) + val event = randomNotificationChannelPartialUpdatedEvent( + cid = slim.cid, + channelType = slim.type, + channelId = slim.id, + channel = slim, + ) + whenever(queryChannelsStateLogic.handleChatEvent(any(), anyOrNull())) doReturn + EventHandlingResult.WatchAndAdd(event.cid) + return event + } + + private fun daysAgo(days: Long): Date = Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(days)) + + @Test + fun `watchAndAddChannel adds the watched channel`() = runTest { + val channel = randomChannel(hidden = false) + givenWatchReturns(channel) + + logic.watchAndAddChannel(channel.cid) + + verify(queryChannelsStateLogic).addChannelsState(listOf(channel)) + } + + @Test + fun `watchAndAddChannel does not add a hidden channel to a grouped query`() = runTest { + val channel = randomChannel(hidden = true) + givenWatchReturns(channel) + + logic.watchAndAddChannel(channel.cid) + + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + } + + private fun givenWatchReturns(channel: Channel) { + val channelClient = mock { + on { watch() } doReturn channel.asCall() + } + whenever(client.channel(channel.cid)) doReturn channelClient + } + + private companion object { + private const val GROUP_KEY = "test-group" + private val DEFAULT_SORT = QuerySortByField.descByName("last_updated") + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdateTest.kt new file mode 100644 index 000000000000..2b5fc6246b39 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/utils/internal/ChannelPartialUpdateTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.utils.internal + +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.toChannelData +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomDate +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomString +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ChannelPartialUpdateTest { + + private val partial = Channel( + id = randomString(), + type = randomString(), + name = "New name", + image = "new.png", + frozen = true, + team = "team1", + createdAt = randomDate(), + updatedAt = randomDate(), + extraData = mapOf("group" to "vip", "disabled" to true), + ) + + @Test + fun `merging a partial update applies the fields it carries`() { + val local = randomChannel(frozen = false, extraData = emptyMap()) + + val merged = local.mergePartialUpdate(partial) + + assertEquals("New name", merged.name) + assertEquals("new.png", merged.image) + assertEquals(true, merged.frozen) + assertEquals("team1", merged.team) + assertEquals(partial.createdAt, merged.createdAt) + assertEquals(partial.updatedAt, merged.updatedAt) + } + + @Test + fun `merging a partial update keeps the fields it does not carry`() { + val local = randomChannel(members = listOf(randomMember()), lastMessageAt = randomDate()) + + val merged = local.mergePartialUpdate(partial) + + assertEquals(local.id, merged.id) + assertEquals(local.type, merged.type) + assertEquals(local.members, merged.members) + assertEquals(local.read, merged.read) + assertEquals(local.config, merged.config) + assertEquals(local.membership, merged.membership) + assertEquals(local.lastMessageAt, merged.lastMessageAt) + } + + @Test + fun `merging a partial update replaces the custom data and keeps the server fields`() { + val local = randomChannel( + extraData = mapOf( + "group" to "other", + "touched_at" to "2026-10-01T00:00:00.000Z", + "disabled" to false, + "muted" to true, + "truncated_at" to "2026-09-01T00:00:00.000Z", + ), + ) + + val merged = local.mergePartialUpdate(partial) + + val expected = mapOf( + "group" to "vip", + "disabled" to true, + "muted" to true, + "truncated_at" to "2026-09-01T00:00:00.000Z", + ) + assertEquals(expected, merged.extraData) + } + + @Test + fun `merging a partial update into channel data matches merging it into the channel`() { + val local = randomChannel(extraData = mapOf("group" to "other", "muted" to true)) + + val merged = local.toChannelData().mergePartialUpdate(partial) + + assertEquals(local.mergePartialUpdate(partial).toChannelData(), merged) + } +} 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 4c4cedfc93e4..e42f3b6fbb8d 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 @@ -52,6 +52,7 @@ import io.getstream.chat.android.client.createNewMessageWithoutUnreadCountsEvent import io.getstream.chat.android.client.createNotificationAddedToChannelEventStringJson import io.getstream.chat.android.client.createNotificationChannelDeletedEventStringJson import io.getstream.chat.android.client.createNotificationChannelMutesUpdatedEventStringJson +import io.getstream.chat.android.client.createNotificationChannelPartialUpdatedEventStringJson import io.getstream.chat.android.client.createNotificationChannelTruncatedEventStringJson import io.getstream.chat.android.client.createNotificationInviteAcceptedEventStringJson import io.getstream.chat.android.client.createNotificationInviteRejectedEventStringJson @@ -117,6 +118,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -605,6 +607,25 @@ internal object EventArguments { channel = wireChannel, groupedUnreadChannels = groupedUnreadChannels, ) + private val notificationChannelPartialUpdatedEvent = NotificationChannelPartialUpdatedEvent( + type = EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED, + createdAt = date, + rawCreatedAt = streamDateFormatter.format(date), + cid = cid, + channelType = channelType, + channelId = channelId, + channel = Channel( + id = channelId, + type = channelType, + name = "Channel name", + image = "https://example.com/channel.png", + frozen = true, + createdAt = date, + updatedAt = date, + team = "team1", + extraData = mutableMapOf("group" to "current", "disabled" to false), + ), + ) private val notificationChannelTruncatedEvent = NotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, createdAt = date, @@ -1135,6 +1156,7 @@ internal object EventArguments { messageUpdatedEvent, notificationAddedToChannelEvent, notificationChannelDeletedEvent, + notificationChannelPartialUpdatedEvent, notificationChannelTruncatedEvent, notificationInviteAcceptedEvent, notificationInviteRejectedEvent, @@ -1206,6 +1228,7 @@ internal object EventArguments { Arguments.of(createMessageUpdatedEventStringJson(), messageUpdatedEvent), Arguments.of(createNotificationAddedToChannelEventStringJson(), notificationAddedToChannelEvent), Arguments.of(createNotificationChannelDeletedEventStringJson(), notificationChannelDeletedEvent), + Arguments.of(createNotificationChannelPartialUpdatedEventStringJson(), notificationChannelPartialUpdatedEvent), Arguments.of(createNotificationChannelTruncatedEventStringJson(), notificationChannelTruncatedEvent), Arguments.of(createNotificationInviteAcceptedEventStringJson(), notificationInviteAcceptedEvent), Arguments.of(createNotificationInviteRejectedEventStringJson(), notificationInviteRejectedEvent), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedNotificationEventParsingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedNotificationEventParsingTest.kt index 08290078b0ef..717d89c11094 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedNotificationEventParsingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/GeneratedNotificationEventParsingTest.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.parser2 import com.squareup.moshi.JsonDataException import io.getstream.chat.android.client.createNotificationAddedToChannelEventStringJson import io.getstream.chat.android.client.createNotificationChannelDeletedEventStringJson +import io.getstream.chat.android.client.createNotificationChannelPartialUpdatedEventStringJson import io.getstream.chat.android.client.createNotificationChannelTruncatedEventStringJson import io.getstream.chat.android.client.createNotificationInviteAcceptedEventStringJson import io.getstream.chat.android.client.createNotificationInviteRejectedEventStringJson @@ -26,6 +27,7 @@ import io.getstream.chat.android.client.createNotificationInvitedEventStringJson import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -91,6 +93,7 @@ internal class GeneratedNotificationEventParsingTest { private val channelEvents = listOf( createNotificationChannelDeletedEventStringJson() to NotificationChannelDeletedEvent::class, createNotificationChannelTruncatedEventStringJson() to NotificationChannelTruncatedEvent::class, + createNotificationChannelPartialUpdatedEventStringJson() to NotificationChannelPartialUpdatedEvent::class, ) private val addedToChannelEvent = 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 234f02c02b9e..a31b2b7ed794 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1021,6 +1021,7 @@ public final class io/getstream/chat/android/models/EventType { public static final field NOTIFICATION_ADDED_TO_CHANNEL Ljava/lang/String; public static final field NOTIFICATION_CHANNEL_DELETED Ljava/lang/String; public static final field NOTIFICATION_CHANNEL_MUTES_UPDATED Ljava/lang/String; + public static final field NOTIFICATION_CHANNEL_PARTIAL_UPDATED Ljava/lang/String; public static final field NOTIFICATION_CHANNEL_TRUNCATED Ljava/lang/String; public static final field NOTIFICATION_INVITED Ljava/lang/String; public static final field NOTIFICATION_INVITE_ACCEPTED Ljava/lang/String; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt index 1db27d3bd495..63cab171bd4d 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt @@ -58,6 +58,7 @@ public object EventType { public const val THREAD_UPDATED: String = "thread.updated" public const val NOTIFICATION_CHANNEL_TRUNCATED: String = "notification.channel_truncated" public const val NOTIFICATION_CHANNEL_DELETED: String = "notification.channel_deleted" + public const val NOTIFICATION_CHANNEL_PARTIAL_UPDATED: String = "notification.channel_partial_updated" public const val NOTIFICATION_MARK_READ: String = "notification.mark_read" public const val NOTIFICATION_MARK_UNREAD: String = "notification.mark_unread" public const val NOTIFICATION_INVITED: String = "notification.invited" From 960ab07872541d8bde37522c4798627b80b5be26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 16 Oct 2026 09:59:16 +0100 Subject: [PATCH 2/4] Drop an unused JUnit 4 rule from the grouped watch tests JUnit 5 does not apply JUnit 4 rules, and the tests do not use it. --- .../internal/QueryChannelsLogicGroupedWatchTest.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt index b4cba4218fce..f66e48a71e2d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -28,11 +28,9 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomString -import io.getstream.chat.android.test.TestCoroutineRule import io.getstream.chat.android.test.asCall import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import org.junit.Rule import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -48,9 +46,6 @@ import java.util.concurrent.TimeUnit internal class QueryChannelsLogicGroupedWatchTest { - @get:Rule - val testCoroutines = TestCoroutineRule() - private val client: ChatClient = mock() private val queryChannelsStateLogic: QueryChannelsStateLogic = mock() private val queryChannelsDatabaseLogic: QueryChannelsDatabaseLogic = mock() From 1ccf6bd48a6af3c42e4318547e942ad882f9f93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 16 Oct 2026 10:22:48 +0100 Subject: [PATCH 3/4] Move the loaded-channels check for partial updates out of QueryChannelsLogic Tests that mock QueryChannelsLogic drop its coverage for later tests in the same JVM. The check is a pure function of the query state, so it now lives in a top-level function, and the tests also cover a partial channel without name, image or team and a missing channel data. --- .../internal/GroupedPartialUpdateWatch.kt | 54 +++++++++++++++++++ .../internal/QueryChannelsLogic.kt | 25 ++------- .../client/api2/mapping/DomainMappingTest.kt | 11 ++++ .../internal/ChannelEventHandlerImplTest.kt | 1 + .../ChannelEventHandlerLegacyImplTest.kt | 2 + 5 files changed, 73 insertions(+), 20 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt new file mode 100644 index 000000000000..1f0e4ad5e028 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal + +import io.getstream.chat.android.client.api.event.EventHandlingResult +import io.getstream.chat.android.client.api.state.QueryChannelsState +import io.getstream.chat.android.client.events.ChatEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate +import io.getstream.chat.android.models.Channel + +/** + * Turns a [EventHandlingResult.WatchAndAdd] for a partial channel update into [EventHandlingResult.Skip] when the + * channel cannot be shown in the grouped list of [state] without the pages before it. A channel below the loaded + * channels is shown when its page loads, so a partial update on an old channel does not watch it. Other results and + * events are returned unchanged. + * + * @param event The event that produced this result. + * @param cachedChannel The local copy of the event's channel, or `null` when there is none. + * @param state The state of the grouped query. + */ +internal fun EventHandlingResult.skipWatchBelowLoadedChannels( + event: ChatEvent, + cachedChannel: Channel?, + state: QueryChannelsState, +): EventHandlingResult { + if (this !is EventHandlingResult.WatchAndAdd || event !is NotificationChannelPartialUpdatedEvent) return this + val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel + return if (state.isWithinLoadedChannels(channel)) this else EventHandlingResult.Skip +} + +/** + * Whether [channel] sorts within the loaded channels: the group has no more pages, or the channel sorts at or above + * the last loaded channel. Without local data the channel has no last message time, so its position is a lower bound. + */ +private fun QueryChannelsState.isWithinLoadedChannels(channel: Channel): Boolean { + if (endOfChannels.value) return true + val lastLoaded = channels.value?.lastOrNull() ?: return true + return sort.comparator.compare(channel, lastLoaded) <= 0 +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index fd0ffd150d7b..6af145c69a49 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -24,13 +24,11 @@ import io.getstream.chat.android.client.api.state.ChannelsStateData import io.getstream.chat.android.client.api.state.querychannels.GroupedQueryConfig import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.CidEvent -import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.internal.state.event.handler.grouped.internal.isUnreadInGroupFor import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier -import io.getstream.chat.android.client.internal.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelConfig @@ -671,27 +669,14 @@ internal class QueryChannelsLogic( return chatEvents.map { event -> val channel = (event as? CidEvent)?.let { resolvedChannels[it.cid] } val result = queryChannelsStateLogic.handleChatEvent(event, channel) - val isPartialUpdateBelowLoadedChannels = event is NotificationChannelPartialUpdatedEvent && - result is EventHandlingResult.WatchAndAdd && - !isWithinLoadedChannels(channel?.mergePartialUpdate(event.channel) ?: event.channel) - if (isPartialUpdateBelowLoadedChannels) EventHandlingResult.Skip else result + when (identifier) { + is QueryChannelsIdentifier.Grouped -> + result.skipWatchBelowLoadedChannels(event, channel, queryChannelsStateLogic.getState()) + else -> result + } } } - /** - * Whether [channel] can be shown in a grouped list without the pages before it: the group has no more pages, or - * the channel sorts at or above the last loaded channel. A channel below them is shown when its page loads, so a - * partial update on an old channel does not watch it. Without local data the channel has no last message time, - * so its position is a lower bound. - */ - private fun isWithinLoadedChannels(channel: Channel): Boolean { - if (identifier !is QueryChannelsIdentifier.Grouped) return true - val state = queryChannelsStateLogic.getState() - if (state.endOfChannels.value) return true - val lastLoaded = state.channels.value?.lastOrNull() ?: return true - return state.sort.comparator.compare(channel, lastLoaded) <= 0 - } - /** * Refreshes a single channel. * @see [refreshChannelsState] diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index bebc65aa6c22..5808dc835332 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -710,6 +710,17 @@ internal class DomainMappingTest { assertEquals(mapOf("group" to "current", "disabled" to true), result.extraData) } + @Test + fun `ChannelPartialUpdateResponse without name, image or team maps them to empty values`() { + val sut = Fixture().get() + + val result = with(sut) { channelPartialUpdateResponse().copy(custom = emptyMap(), team = null).toDomain() } + + assertEquals("", result.name) + assertEquals("", result.image) + assertEquals("", result.team) + } + private fun channelPartialUpdateResponse() = ChannelPartialUpdateResponse( cid = "messaging:123", createdAt = Date(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt index a4907ff2897c..ebe35498bd74 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelEventHandlerImplTest.kt @@ -868,6 +868,7 @@ internal class ChannelEventHandlerImplTest { val update = argumentCaptor<(ChannelData?) -> ChannelData?>() verify(state).updateChannelData(update.capture()) assertEquals(currentData.mergePartialUpdate(partial), update.firstValue(currentData)) + assertNull(update.firstValue(null)) } @Test diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt index 331644ba1d9d..d88dea129cc5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImplTest.kt @@ -92,6 +92,7 @@ import io.getstream.chat.android.randomThreadInfo import io.getstream.chat.android.randomUser import kotlinx.coroutines.flow.MutableStateFlow import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.kotlin.any @@ -577,6 +578,7 @@ internal class ChannelEventHandlerLegacyImplTest { val update = argumentCaptor<(ChannelData?) -> ChannelData?>() verify(stateLogic).updateChannelData(update.capture()) assertEquals(currentData.mergePartialUpdate(partial), update.firstValue(currentData)) + assertNull(update.firstValue(null)) } @Test From f95af730c9a1efce314c828b84627e08dd53066c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 19 Oct 2026 11:34:45 +0100 Subject: [PATCH 4/4] Check the cutoff only for listed channels and move group badges for channels that are not loaded A stored copy's last activity can be stale, so a partial update on a channel that is not in the list only checks its group, and the list checks the cutoff after the watch. The group badge delta takes the old group and unread state from the stored channel when the channel has no active state, which is the usual case for this event. --- .../internal/GroupAwareChatEventHandler.kt | 21 ++++++------ .../internal/GroupedUnreadChannelsUpdater.kt | 17 ++++++---- .../internal/EventHandlerSequential.kt | 2 +- .../internal/GroupedPartialUpdateWatch.kt | 3 +- .../GroupAwareChatEventHandlerTest.kt | 18 +++++++++++ .../GroupedUnreadChannelsUpdaterTest.kt | 32 +++++++++++++++++-- ...EventHandlerSequentialPartialUpdateTest.kt | 30 +++++++++++++++++ 7 files changed, 102 insertions(+), 21 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index 57e9f9e2f3f5..e0634227a74f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -129,23 +129,22 @@ internal class GroupAwareChatEventHandler( * lacks data such as members and the last message time, so a channel that is not in the list yet is watched to * get its full data instead of being added from the event. * - * Without [cachedChannel], only the group is checked: the last activity is unknown, so the cutoff is left to the - * list, which checks the watched channel before showing it. The event is only sent to members, and a hidden - * channel is dropped after the watch. The query skips the watch when the channel sorts below its loaded pages. + * The cutoff is checked only for a channel in the list, whose last activity is up to date. For any other + * channel only the group is checked: without [cachedChannel] the last activity is unknown, and a stored copy can + * be stale, so the cutoff is left to the list, which checks the watched channel before showing it. The event is + * only sent to members, and a hidden channel is dropped after the watch. The query skips the watch when the + * channel sorts below its loaded pages. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, cachedChannel: Channel?, filter: FilterObject, ): EventHandlingResult { - val belongsHere = if (cachedChannel == null) { - groupMatches(event.channel, filter) - } else { - channelBelongsHere(cachedChannel.mergePartialUpdate(event.channel), filter) && - isCurrentUserMember(cachedChannel) && - cachedChannel.hidden != true - } + val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel val isInList = channels.value?.containsKey(event.cid) == true + val matches = if (isInList) channelBelongsHere(channel, filter) else groupMatches(channel, filter) + val belongsHere = matches && + (cachedChannel == null || (isCurrentUserMember(cachedChannel) && cachedChannel.hidden != true)) return when { belongsHere && !isInList -> EventHandlingResult.WatchAndAdd(event.cid) !belongsHere && isInList -> EventHandlingResult.Remove(event.cid) @@ -157,7 +156,7 @@ internal class GroupAwareChatEventHandler( resolver.resolve(channel).contains(groupKey) && filter.matchesGroupedChannel(group = channel.group, queryTimestamp = channel.queryTimestamp, now = now()) - /** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown. */ + /** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown or may be stale. */ private fun groupMatches(channel: Channel, filter: FilterObject): Boolean { val checkedAt = now() return resolver.resolve(channel).contains(groupKey) && diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt index e66323018257..5b4fc0468a19 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt @@ -95,7 +95,7 @@ internal class GroupedUnreadChannelsUpdater( current: Map, batchId: Int, event: ChannelUpdatedEvent, - ): Map = applyDelta(current, batchId, event.cid, event.channel) + ): Map = applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid)) /** * `channel.updated_by_user` delta. Same semantics as the [ChannelUpdatedEvent] overload. @@ -104,17 +104,21 @@ internal class GroupedUnreadChannelsUpdater( current: Map, batchId: Int, event: ChannelUpdatedByUserEvent, - ): Map = applyDelta(current, batchId, event.cid, event.channel) + ): Map = applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid)) /** * `notification.channel_partial_updated` delta. Same semantics as the [ChannelUpdatedEvent] overload. The - * event's channel carries the custom data, so it holds the new group. + * event's channel carries the custom data, so it holds the new group. The event goes to members who are not + * watching the channel, so it usually has no active state: its old group and unread state then come from + * [storedChannel], which is only read in that case. */ - fun calculateUpdatedCounts( + suspend fun calculateUpdatedCounts( current: Map, batchId: Int, event: NotificationChannelPartialUpdatedEvent, - ): Map = applyDelta(current, batchId, event.cid, event.channel) + storedChannel: suspend () -> Channel?, + ): Map = + applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid) ?: storedChannel()) /** * Records that channel [cid] has been removed within [batchId] — either deleted, or the @@ -146,10 +150,11 @@ internal class GroupedUnreadChannelsUpdater( batchId: Int, cid: String, newChannel: Channel, + oldChannel: Channel?, ): Map { rotateBatchIfNeeded(batchId) if (cid in removedCids || cid in processedCids) return current - val oldChannel = cachedChannel(cid) ?: return current + if (oldChannel == null) return current val oldGroup = oldChannel.group val newGroup = newChannel.group if (oldGroup == newGroup) return current diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index 3fd2884981f0..8b7c6fda4b9c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -461,7 +461,7 @@ internal class EventHandlerSequential( } (event as? NotificationChannelPartialUpdatedEvent)?.let { e -> groupedUnreadChannels = groupedUnreadChannelsUpdater - .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) + .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) { repos.selectChannel(e.cid) } } (event as? HasOwnUser)?.let { modifyValuesFromUser(it.me) } (event as? HasUnreadThreadCounts)?.let { modifyUnreadThreadsCount(it) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt index 1f0e4ad5e028..14bb5b9e256f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt @@ -45,7 +45,8 @@ internal fun EventHandlingResult.skipWatchBelowLoadedChannels( /** * Whether [channel] sorts within the loaded channels: the group has no more pages, or the channel sorts at or above - * the last loaded channel. Without local data the channel has no last message time, so its position is a lower bound. + * the last loaded channel. Without local data the channel has no last message time, so its position is a lower bound: + * a channel with recent activity but no local copy can be skipped, and it then shows after the next refresh. */ private fun QueryChannelsState.isWithinLoadedChannels(channel: Channel): Boolean { if (endOfChannels.value) return true diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index c768b70ace4f..796990ae13e4 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -570,6 +570,24 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) } + @Test + fun `Given a stale copy past the cutoff and not in the list When a partial update moves it into the group Should watch and add`() { + // The copy's last activity can be stale, so the list checks the cutoff after the watch. + val currentUser = randomUser() + val channel = randomChannel( + createdAt = daysAgo(30), + lastMessageAt = daysAgo(20), + extraData = mapOf("group" to "other"), + ) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + @Test fun `Given a cached channel not in the list When a partial update moves it into the group Should watch and add`() { val currentUser = randomUser() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt index b3113a6ae15c..b63ae2b11723 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt @@ -37,6 +37,7 @@ 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 kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test @@ -204,16 +205,43 @@ internal class GroupedUnreadChannelsUpdaterTest { } @Test - fun `NotificationChannelPartialUpdatedEvent applies the same migration semantics`() { + fun `NotificationChannelPartialUpdatedEvent applies the same migration semantics`() = runTest { seedActiveChannel(oldGroup = "a", unreadMessages = 2) val current = mapOf("a" to 3, "b" to 1) val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) - val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { + error("The stored channel is not read for an active channel") + } assertEquals(mapOf("a" to 2, "b" to 2), next) } + @Test + fun `NotificationChannelPartialUpdatedEvent for an inactive channel migrates the count of its stored copy`() = + runTest { + val stored = channel(group = "a").copy( + read = listOf(randomChannelUserRead(user = User(id = CURRENT_USER_ID), unreadMessages = 2)), + ) + val current = mapOf("a" to 3, "b" to 1) + val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { stored } + + assertEquals(mapOf("a" to 2, "b" to 2), next) + } + + @Test + fun `NotificationChannelPartialUpdatedEvent for a channel with no local copy returns current map unchanged`() = + runTest { + val current = mapOf("a" to 3, "b" to 1) + val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { null } + + assertSame(current, next) + } + // endregion // region Same-batch dedup diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt index 623c4de820a8..807cbeaf6400 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialPartialUpdateTest.kt @@ -21,6 +21,7 @@ import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.models.Channel import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomChannelUserRead import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomUser import kotlinx.coroutines.test.runTest @@ -61,6 +62,35 @@ internal class EventHandlerSequentialPartialUpdateTest { mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) } + @Test + fun `NotificationChannelPartialUpdatedEvent migrates grouped unread count of a stored channel that is not active`() = + runTest { + val stored = randomChannel( + id = "channel-id", + type = "messaging", + extraData = mapOf("group" to "a"), + read = listOf(randomChannelUserRead(user = currentUser, unreadMessages = 1)), + ) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn listOf(stored) + whenever(repos.selectThreads(any())) doReturn emptyList() + whenever(repos.selectChannel(stored.cid)) doReturn stored + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(mapOf("a" to 1, "b" to 0)) + } + val partialChannel = Channel(id = "channel-id", type = "messaging", extraData = mapOf("group" to "b")) + val handler = EventHandlerSequentialTest.Fixture() + .withCurrentUser(currentUser) + .withRepositoryFacade(repos) + .withMutableGlobalState(mutableGlobalState) + .get(this) + + handler.handleEvents(randomNotificationChannelPartialUpdatedEvent(cid = stored.cid, channel = partialChannel)) + + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) + } + @Test fun `When handling NotificationChannelPartialUpdatedEvent, The stored channel should get the partial update`() = runTest {