From 840914fa48657339ee14ae222fbafa2641cae664 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 15 Oct 2026 16:00:31 +0100 Subject: [PATCH 1/7] Update grouped channel lists on partial channel updates for members who are not watching Parse notification.channel_partial_updated into NotificationChannelPartialUpdatedEvent. The event carries a slim channel, so it 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. --- .../chat/android/client/test/Mother.kt | 17 ++ .../api/stream-chat-android-client.api | 23 +++ .../client/api2/mapping/DomainMapping.kt | 22 +++ .../client/api2/mapping/EventMapping.kt | 19 ++ .../client/api2/model/dto/ChannelDtos.kt | 16 ++ .../client/api2/model/dto/EventDtos.kt | 10 + .../android/client/channel/ChannelClient.kt | 2 + .../chat/android/client/events/ChatEvent.kt | 19 ++ .../client/parser2/adapters/EventAdapter.kt | 4 + .../android/client/EventChatJsonProvider.kt | 26 +++ .../api2/mapping/EventMappingTestArguments.kt | 43 +++++ .../android/client/parser/EventArguments.kt | 23 +++ .../api/stream-chat-android-core.api | 1 + .../chat/android/models/EventType.kt | 1 + .../internal/GroupAwareChatEventHandler.kt | 28 +++ .../internal/GroupedUnreadChannelsUpdater.kt | 11 ++ .../internal/EventHandlerSequential.kt | 12 ++ .../channel/internal/ChannelEventHandler.kt | 5 + .../utils/internal/ChannelPartialUpdate.kt | 71 +++++++ .../GroupAwareChatEventHandlerTest.kt | 180 ++++++++++++++++++ .../GroupedUnreadChannelsUpdaterTest.kt | 23 +++ .../internal/EventHandlerSequentialTest.kt | 70 +++++++ .../internal/ChannelEventHandlerTest.kt | 20 ++ .../internal/ChannelPartialUpdateTest.kt | 102 ++++++++++ 24 files changed, 748 insertions(+) create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/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 0b73bbc6c882..038c835dc3c6 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 @@ -38,6 +38,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 @@ -599,6 +600,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 d80752b33282..abab38039fcd 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -1954,6 +1954,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 f5e7b26b8589..129b11904e2a 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 @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.api2.model.dto.DeliveryReceiptsDto import io.getstream.chat.android.client.api2.model.dto.DeviceDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelMuteDto +import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelPartialUpdateDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelUserRead import io.getstream.chat.android.client.api2.model.dto.DownstreamDraftDto import io.getstream.chat.android.client.api2.model.dto.DownstreamFlagDto @@ -193,6 +194,24 @@ internal class DomainMapping( ).syncUnreadCountWithReads(currentUserIdProvider()) .let(channelTransformer::transform) + /** + * Transforms [DownstreamChannelPartialUpdateDto] into a [Channel] holding only the fields the DTO carries. + * The `name` and `image` custom fields fill [Channel.name] and [Channel.image], like they do for a full channel. + */ + internal fun DownstreamChannelPartialUpdateDto.toDomain(): Channel = + Channel( + id = id, + type = type, + name = custom[CHANNEL_NAME_FIELD] as? String ?: "", + image = custom[CHANNEL_IMAGE_FIELD] as? String ?: "", + frozen = frozen, + createdAt = created_at, + updatedAt = updated_at, + team = team, + extraData = (custom - CHANNEL_NAME_FIELD - CHANNEL_IMAGE_FIELD + (CHANNEL_DISABLED_FIELD to disabled)) + .toMutableMap(), + ) + internal fun DownstreamChannelDto.toChannelInfo(): ChannelInfo = ChannelInfo( cid = cid, @@ -923,5 +942,8 @@ internal class DomainMapping( private companion object { private const val FIELD_LAST_MESSAGE_AT = "last_message_at" private const val FIELD_LAST_UPDATED = "last_updated" + private const val CHANNEL_NAME_FIELD = "name" + private const val CHANNEL_IMAGE_FIELD = "image" + private const val CHANNEL_DISABLED_FIELD = "disabled" } } 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 2a30dae7f77e..5638ef8fa38f 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 @@ -53,6 +53,7 @@ import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelMutesUpdatedEventDto +import io.getstream.chat.android.client.api2.model.dto.NotificationChannelPartialUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelTruncatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteAcceptedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteRejectedEventDto @@ -120,6 +121,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 @@ -197,6 +199,7 @@ internal class EventMapping( is NotificationAddedToChannelEventDto -> toDomain() is NotificationChannelDeletedEventDto -> toDomain() is NotificationChannelMutesUpdatedEventDto -> toDomain() + is NotificationChannelPartialUpdatedEventDto -> toDomain() is NotificationChannelTruncatedEventDto -> toDomain() is NotificationInviteAcceptedEventDto -> toDomain() is NotificationInviteRejectedEventDto -> toDomain() @@ -540,6 +543,22 @@ internal class EventMapping( ) } + /** + * Transforms [NotificationChannelPartialUpdatedEventDto] to [NotificationChannelPartialUpdatedEvent]. + */ + private fun NotificationChannelPartialUpdatedEventDto.toDomain(): NotificationChannelPartialUpdatedEvent = + with(domainMapping) { + NotificationChannelPartialUpdatedEvent( + type = type, + createdAt = created_at.date, + rawCreatedAt = created_at.rawDate, + cid = cid, + channelType = channel_type, + channelId = channel_id, + channel = channel.toDomain(), + ) + } + /** * Transforms [NotificationChannelTruncatedEventDto] to [NotificationChannelTruncatedEvent]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt index c72ace9f6902..d5ea58e7e63e 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt @@ -49,6 +49,22 @@ internal data class DownstreamChannelDto( val extraData: Map, ) : ExtraDataDto +/** + * Model holding the channel fields delivered with `notification.channel_partial_updated` events. Unlike + * [DownstreamChannelDto], the custom fields (including `name` and `image`) are nested in [custom]. + */ +@JsonClass(generateAdapter = true) +internal data class DownstreamChannelPartialUpdateDto( + val id: String, + val type: String, + val created_at: Date?, + val updated_at: Date?, + val frozen: Boolean, + val disabled: Boolean, + val team: String = "", + val custom: Map = emptyMap(), +) + /** * Model holding custom channel fields delivered with `message.new` events. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index d214cfbc5f2f..2137b51cbbcb 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -260,6 +260,16 @@ internal data class NotificationChannelTruncatedEventDto( val grouped_unread_channels: Map? = null, ) : ChatEventDto() +@JsonClass(generateAdapter = true) +internal data class NotificationChannelPartialUpdatedEventDto( + val type: String, + val created_at: ExactDate, + val cid: String, + val channel_type: String, + val channel_id: String, + val channel: DownstreamChannelPartialUpdateDto, +) : ChatEventDto() + @JsonClass(generateAdapter = true) internal data class NotificationInviteAcceptedEventDto( val type: String, 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 788b390967cd..b8098ea2ec55 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 @@ -277,6 +278,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 083ede23e4c4..1cb84f6eec94 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 @@ -417,6 +417,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.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/parser2/adapters/EventAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt index eab4fac67898..b27c8c892e0c 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 @@ -55,6 +55,7 @@ import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelMutesUpdatedEventDto +import io.getstream.chat.android.client.api2.model.dto.NotificationChannelPartialUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelTruncatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteAcceptedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteRejectedEventDto @@ -151,6 +152,8 @@ internal class EventDtoAdapter( private val notificationChannelDeletedEventAdapter = moshi.adapter(NotificationChannelDeletedEventDto::class.java) private val notificationChannelTruncatedEventAdapter = moshi.adapter(NotificationChannelTruncatedEventDto::class.java) + private val notificationChannelPartialUpdatedEventAdapter = + moshi.adapter(NotificationChannelPartialUpdatedEventDto::class.java) private val userPresenceChangedEventAdapter = moshi.adapter(UserPresenceChangedEventDto::class.java) private val userUpdatedEventAdapter = moshi.adapter(UserUpdatedEventDto::class.java) private val userDeletedEventAdapter = moshi.adapter(UserDeletedEventDto::class.java) @@ -233,6 +236,7 @@ internal class EventDtoAdapter( EventType.NOTIFICATION_CHANNEL_MUTES_UPDATED -> notificationChannelMutesUpdatedEventAdapter EventType.NOTIFICATION_CHANNEL_DELETED -> notificationChannelDeletedEventAdapter EventType.NOTIFICATION_CHANNEL_TRUNCATED -> notificationChannelTruncatedEventAdapter + EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED -> notificationChannelPartialUpdatedEventAdapter EventType.USER_PRESENCE_CHANGED -> userPresenceChangedEventAdapter EventType.USER_UPDATED -> userUpdatedEventAdapter EventType.USER_DELETED -> userDeletedEventAdapter 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 f2a62c2d693a..e92064b004a0 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 @@ -237,6 +237,32 @@ 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", + "custom": { + "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/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index f658b38df10c..0371e43c93e9 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 @@ -34,6 +34,7 @@ import io.getstream.chat.android.client.api2.model.dto.ConnectingEventDto import io.getstream.chat.android.client.api2.model.dto.ConnectionErrorEventDto import io.getstream.chat.android.client.api2.model.dto.DisconnectedEventDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelCustomDto +import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelPartialUpdateDto import io.getstream.chat.android.client.api2.model.dto.DraftMessageDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.DraftMessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.ErrorEventDto @@ -52,6 +53,7 @@ import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelMutesUpdatedEventDto +import io.getstream.chat.android.client.api2.model.dto.NotificationChannelPartialUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelTruncatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteAcceptedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteRejectedEventDto @@ -119,6 +121,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 @@ -151,6 +154,7 @@ import io.getstream.chat.android.client.events.UserUpdatedEvent import io.getstream.chat.android.client.events.VoteCastedEvent import io.getstream.chat.android.client.events.VoteChangedEvent import io.getstream.chat.android.client.events.VoteRemovedEvent +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelInfo import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.NoOpChannelTransformer @@ -483,6 +487,24 @@ internal object EventMappingTestArguments { me = USER, ) + private val notificationChannelPartialUpdatedDto = NotificationChannelPartialUpdatedEventDto( + type = EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED, + created_at = EXACT_DATE, + cid = CID, + channel_type = CHANNEL_TYPE, + channel_id = CHANNEL_ID, + channel = DownstreamChannelPartialUpdateDto( + id = CHANNEL_ID, + type = CHANNEL_TYPE, + created_at = DATE, + updated_at = DATE, + frozen = true, + disabled = true, + team = "team1", + custom = mapOf("name" to "Channel name", "image" to "image.png", "group" to "current"), + ), + ) + private val notificationChannelTruncatedDto = NotificationChannelTruncatedEventDto( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, created_at = EXACT_DATE, @@ -1139,6 +1161,26 @@ internal object EventMappingTestArguments { me = with(domainMapping) { notificationChannelMutesUpdatesDto.me.toDomain() }, ) + private val notificationChannelPartialUpdated = NotificationChannelPartialUpdatedEvent( + type = notificationChannelPartialUpdatedDto.type, + createdAt = notificationChannelPartialUpdatedDto.created_at.date, + rawCreatedAt = notificationChannelPartialUpdatedDto.created_at.rawDate, + cid = notificationChannelPartialUpdatedDto.cid, + channelType = notificationChannelPartialUpdatedDto.channel_type, + channelId = notificationChannelPartialUpdatedDto.channel_id, + channel = Channel( + id = CHANNEL_ID, + type = CHANNEL_TYPE, + name = "Channel name", + image = "image.png", + frozen = true, + createdAt = DATE, + updatedAt = DATE, + team = "team1", + extraData = mapOf("group" to "current", "disabled" to true), + ), + ) + private val notificationChannelTruncated = NotificationChannelTruncatedEvent( type = notificationChannelTruncatedDto.type, createdAt = notificationChannelTruncatedDto.created_at.date, @@ -1589,6 +1631,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/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index 7a5d518537b2..c13051dcd9d1 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 @@ -50,6 +50,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 @@ -115,6 +116,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 @@ -544,6 +546,25 @@ internal object EventArguments { channel = channel, 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, @@ -1049,6 +1070,7 @@ internal object EventArguments { messageUpdatedEvent, notificationAddedToChannelEvent, notificationChannelDeletedEvent, + notificationChannelPartialUpdatedEvent, notificationChannelTruncatedEvent, notificationInviteAcceptedEvent, notificationInviteRejectedEvent, @@ -1116,6 +1138,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-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index d225ab8f1cdd..b6b2d3aa35e8 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -941,6 +941,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 4200a1492274..0e180974f981 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 @@ -57,6 +57,7 @@ public object EventType { public const val NOTIFICATION_THREAD_MESSAGE_NEW: String = "notification.thread_message_new" 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" diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index a1eee62610e6..ab891f83a75d 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -24,6 +24,7 @@ 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.setup.state.ClientState import io.getstream.chat.android.models.Channel @@ -32,6 +33,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.DefaultChatEventHandler import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import kotlinx.coroutines.flow.StateFlow import java.util.Date @@ -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, is ChannelVisibleEvent, @@ -111,6 +115,30 @@ internal class GroupAwareChatEventHandler( } } + /** + * 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. The membership and hidden guards apply only when + * [cachedChannel] exists: the event is only sent to members, and without a cached channel there is nothing that + * says the user left or hid it. + */ + private fun routePartialUpdate( + event: NotificationChannelPartialUpdatedEvent, + cachedChannel: Channel?, + filter: FilterObject, + ): EventHandlingResult { + val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel + val belongsHere = channelBelongsHere(channel, filter) && + (cachedChannel == null || 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()) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt index c9194a4698c1..69ca04415f58 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt @@ -22,6 +22,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 @@ -106,6 +107,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-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt index a4adeaa775a7..909a58969776 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt @@ -52,6 +52,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 @@ -123,6 +124,7 @@ import io.getstream.chat.android.state.plugin.logic.querychannels.internal.Query import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineExceptionHandler @@ -451,6 +453,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) @@ -806,6 +812,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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt index bcd803c8546a..d810fca392e7 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.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.models.Message import io.getstream.chat.android.models.MessageReminder import io.getstream.chat.android.state.event.handler.internal.utils.toChannelUserRead import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate /** * Class responsible for updating the local database based on channel-related events. @@ -217,6 +219,9 @@ internal class ChannelEventHandler( // 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-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt new file mode 100644 index 000000000000..8c1269e16fbd --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.utils.internal + +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelData + +/** + * Channel response fields that the channel model does not declare, so they arrive 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 = setOf( + "auto_translation_enabled", + "auto_translation_language", + "blocked", + "disabled", + "hidden", + "hide_messages_before", + "mute_expires_at", + "muted", + "truncated_at", + "truncated_by", +) + +/** + * 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, + 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, + 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-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index 34c498240878..bc7630e77a52 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -23,6 +23,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 @@ -455,6 +456,185 @@ 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) + } + + /** 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-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt index 96857addfaa4..3cb74a6eb3f0 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt @@ -22,6 +22,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 @@ -201,6 +202,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 @@ -508,6 +520,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-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt index 738139003dac..5a765ddcff45 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -31,6 +31,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 @@ -39,6 +40,7 @@ import io.getstream.chat.android.client.test.randomNotificationMutesUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationRemovedFromChannelEvent import io.getstream.chat.android.client.test.randomPollDeletedEvent import io.getstream.chat.android.client.utils.observable.Disposable +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.Location @@ -282,6 +284,74 @@ internal class EventHandlerSequentialTest { mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) } + @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 = 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 = 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 = Fixture() + .withRepositoryFacade(repos) + .get(this) + + handler.handleEvents(event) + + verify(repos).insertChannels(emptyList()) + } + @Test fun `Two ChannelUpdatedEvents for same cid in one batch apply the migration only once`() = runTest { val cid = "messaging:channel-id" diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt index b94a155c9e77..41463d724f50 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt @@ -49,6 +49,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 @@ -68,7 +69,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 @@ -85,11 +89,14 @@ import io.getstream.chat.android.randomThreadInfo import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.event.handler.internal.utils.toChannelUserRead import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate 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 @@ -537,6 +544,19 @@ internal class ChannelEventHandlerTest { 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-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdateTest.kt new file mode 100644 index 000000000000..23daea0dab89 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/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.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) + } +} From 204e717984efb614a0e24c5010e1454bf4a27fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 15 Oct 2026 16:16:44 +0100 Subject: [PATCH 2/7] Read the inline custom fields of partial channel updates and watch unloaded channels by group only v1 WebSocket connections receive the custom fields at the top level of the channel, so the slim channel is parsed like a full channel. A channel with no local copy is matched on its group only, since its last activity is unknown, and the list applies the cutoff after the watch. Grouped lists skip a watched channel that is hidden. --- .../client/api2/mapping/DomainMapping.kt | 11 +++---- .../client/api2/model/dto/ChannelDtos.kt | 11 ++++--- .../android/client/parser2/MoshiChatParser.kt | 2 ++ .../adapters/DownstreamChannelDtoAdapter.kt | 17 +++++++++++ .../android/client/EventChatJsonProvider.kt | 8 ++--- .../api2/mapping/EventMappingTestArguments.kt | 5 +++- .../internal/GroupAwareChatEventHandler.kt | 26 ++++++++++++----- .../internal/QueryChannelsLogic.kt | 4 ++- .../GroupAwareChatEventHandlerTest.kt | 24 +++++++++++++++ .../internal/QueryChannelsLogicGroupedTest.kt | 29 +++++++++++++++++++ 10 files changed, 112 insertions(+), 25 deletions(-) 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 129b11904e2a..a42f6a4599b0 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 @@ -196,20 +196,19 @@ internal class DomainMapping( /** * Transforms [DownstreamChannelPartialUpdateDto] into a [Channel] holding only the fields the DTO carries. - * The `name` and `image` custom fields fill [Channel.name] and [Channel.image], like they do for a full channel. + * Like for a full channel, the server's `disabled` flag is kept in [Channel.extraData]. */ internal fun DownstreamChannelPartialUpdateDto.toDomain(): Channel = Channel( id = id, type = type, - name = custom[CHANNEL_NAME_FIELD] as? String ?: "", - image = custom[CHANNEL_IMAGE_FIELD] as? String ?: "", + name = name ?: "", + image = image ?: "", frozen = frozen, createdAt = created_at, updatedAt = updated_at, team = team, - extraData = (custom - CHANNEL_NAME_FIELD - CHANNEL_IMAGE_FIELD + (CHANNEL_DISABLED_FIELD to disabled)) - .toMutableMap(), + extraData = (extraData + (CHANNEL_DISABLED_FIELD to disabled)).toMutableMap(), ) internal fun DownstreamChannelDto.toChannelInfo(): ChannelInfo = @@ -942,8 +941,6 @@ internal class DomainMapping( private companion object { private const val FIELD_LAST_MESSAGE_AT = "last_message_at" private const val FIELD_LAST_UPDATED = "last_updated" - private const val CHANNEL_NAME_FIELD = "name" - private const val CHANNEL_IMAGE_FIELD = "image" private const val CHANNEL_DISABLED_FIELD = "disabled" } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt index d5ea58e7e63e..613543a16958 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt @@ -50,20 +50,23 @@ internal data class DownstreamChannelDto( ) : ExtraDataDto /** - * Model holding the channel fields delivered with `notification.channel_partial_updated` events. Unlike - * [DownstreamChannelDto], the custom fields (including `name` and `image`) are nested in [custom]. + * Model holding the channel fields delivered with `notification.channel_partial_updated` events. Like in + * [DownstreamChannelDto], the custom fields are at the top level of the channel and are collected in [extraData]. */ @JsonClass(generateAdapter = true) internal data class DownstreamChannelPartialUpdateDto( + val cid: String, val id: String, val type: String, + val name: String?, + val image: String?, val created_at: Date?, val updated_at: Date?, val frozen: Boolean, val disabled: Boolean, val team: String = "", - val custom: Map = emptyMap(), -) + val extraData: Map, +) : ExtraDataDto /** * Model holding custom channel fields delivered with `message.new` events. 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 1c4a441ef379..e89b8b79b19b 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 @@ -34,6 +34,7 @@ import io.getstream.chat.android.client.parser2.adapters.AttachmentDtoAdapter import io.getstream.chat.android.client.parser2.adapters.CreatePollRequestAdapter import io.getstream.chat.android.client.parser2.adapters.DateAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamChannelDtoAdapter +import io.getstream.chat.android.client.parser2.adapters.DownstreamChannelPartialUpdateDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamMemberDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamMessageDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamModerationDetailsDtoAdapter @@ -71,6 +72,7 @@ internal class MoshiChatParser( .add(DownstreamModerationDetailsDtoAdapter) .add(UpstreamMessageDtoAdapter) .add(DownstreamChannelDtoAdapter) + .add(DownstreamChannelPartialUpdateDtoAdapter) .add(AttachmentDtoAdapter) .add(DownstreamReactionDtoAdapter) .add(UpstreamReactionDtoAdapter) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt index 088ed3212a4f..0937a1f12c8a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt @@ -22,6 +22,7 @@ import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter import com.squareup.moshi.ToJson import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelDto +import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelPartialUpdateDto internal object DownstreamChannelDtoAdapter : CustomObjectDtoAdapter(DownstreamChannelDto::class) { @@ -37,3 +38,19 @@ internal object DownstreamChannelDtoAdapter : @Suppress("UNUSED_PARAMETER") fun toJson(jsonWriter: JsonWriter, value: DownstreamChannelDto): Unit = error("Can't convert this to Json") } + +internal object DownstreamChannelPartialUpdateDtoAdapter : + CustomObjectDtoAdapter(DownstreamChannelPartialUpdateDto::class) { + + @FromJson + fun fromJson( + jsonReader: JsonReader, + mapAdapter: JsonAdapter>, + channelAdapter: JsonAdapter, + ): DownstreamChannelPartialUpdateDto? = parseWithExtraData(jsonReader, mapAdapter, channelAdapter) + + @ToJson + @Suppress("UNUSED_PARAMETER") + fun toJson(jsonWriter: JsonWriter, value: DownstreamChannelPartialUpdateDto): Unit = + error("Can't convert this to Json") +} 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 e92064b004a0..a87d9de89072 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 @@ -254,11 +254,9 @@ internal fun createNotificationChannelPartialUpdatedEventStringJson() = "frozen": true, "disabled": false, "team": "team1", - "custom": { - "name": "Channel name", - "image": "https://example.com/channel.png", - "group": "current" - } + "name": "Channel name", + "image": "https://example.com/channel.png", + "group": "current" } """.trimIndent(), ) 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 0371e43c93e9..f5654f196667 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 @@ -494,14 +494,17 @@ internal object EventMappingTestArguments { channel_type = CHANNEL_TYPE, channel_id = CHANNEL_ID, channel = DownstreamChannelPartialUpdateDto( + cid = CID, id = CHANNEL_ID, type = CHANNEL_TYPE, + name = "Channel name", + image = "image.png", created_at = DATE, updated_at = DATE, frozen = true, disabled = true, team = "team1", - custom = mapOf("name" to "Channel name", "image" to "image.png", "group" to "current"), + extraData = mapOf("group" to "current"), ), ) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index ab891f83a75d..48bfcd1e40e0 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -118,19 +118,24 @@ internal class GroupAwareChatEventHandler( /** * 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. The membership and hidden guards apply only when - * [cachedChannel] exists: the event is only sent to members, and without a cached channel there is nothing that - * says the user left or hid it. + * 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. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, cachedChannel: Channel?, filter: FilterObject, ): EventHandlingResult { - val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel - val belongsHere = channelBelongsHere(channel, filter) && - (cachedChannel == null || isCurrentUserMember(cachedChannel)) && - cachedChannel?.hidden != true + 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) @@ -143,6 +148,13 @@ 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. */ + 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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 97e79d523a9f..dcd388493236 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -289,7 +289,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 +297,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) } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index bc7630e77a52..65375c78258d 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -620,6 +620,30 @@ internal class GroupAwareChatEventHandlerTest { 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( diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt index fe4febcedb35..f66a0599f0f9 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest @@ -34,6 +35,7 @@ import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryCo import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.chat.android.test.TestCoroutineRule +import io.getstream.chat.android.test.asCall import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -844,6 +846,33 @@ internal class QueryChannelsLogicGroupedTest { // endregion + @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" } From f8a1a83fc3feef3134c470eb6823c50d0c8572e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 15 Oct 2026 16:46:40 +0100 Subject: [PATCH 3/7] Apply the channel transformer to partially updated channels The fields the event carries replace local values that were transformed when stored, so they are transformed too. --- .../client/api2/mapping/DomainMapping.kt | 5 ++-- .../client/api2/mapping/DomainMappingTest.kt | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) 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 a42f6a4599b0..8fa085b69ce8 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 @@ -196,7 +196,8 @@ internal class DomainMapping( /** * Transforms [DownstreamChannelPartialUpdateDto] into a [Channel] holding only the fields the DTO carries. - * Like for a full channel, the server's `disabled` flag is kept in [Channel.extraData]. + * Like for a full channel, the server's `disabled` flag is kept in [Channel.extraData] and the channel + * transformer is applied, so the fields it carries replace local values that were transformed too. */ internal fun DownstreamChannelPartialUpdateDto.toDomain(): Channel = Channel( @@ -209,7 +210,7 @@ internal class DomainMapping( updatedAt = updated_at, team = team, extraData = (extraData + (CHANNEL_DISABLED_FIELD to disabled)).toMutableMap(), - ) + ).let(channelTransformer::transform) internal fun DownstreamChannelDto.toChannelInfo(): ChannelInfo = ChannelInfo( 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 f9b8c4b5885a..bd77a02e3a35 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -61,6 +61,7 @@ import io.getstream.chat.android.client.Mother.randomUnreadCountByTeamDto import io.getstream.chat.android.client.Mother.randomUnreadDto import io.getstream.chat.android.client.Mother.randomUnreadThreadDto import io.getstream.chat.android.client.api2.mapping.DomainMappingTest.Companion.toSortDomainArguments +import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelPartialUpdateDto import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadParticipantDto import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.models.Answer @@ -258,6 +259,31 @@ 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 dto = DownstreamChannelPartialUpdateDto( + cid = "messaging:123", + id = "123", + type = "messaging", + name = "Channel name", + image = null, + created_at = null, + updated_at = null, + frozen = false, + disabled = false, + extraData = emptyMap(), + ) + + val sut = Fixture() + .withChannelTransformer(channelTransformer) + .get() + + val result = with(sut) { dto.toDomain() } + + assertEquals("Channel name (transformed)", result.name) + } + @Test fun `DownstreamChannelDto is correctly mapped to Channel`() { val downstreamChannelDto = randomDownstreamChannelDto() From d4c2a981c358e51947e5cae418d556b2d519c607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 16 Oct 2026 08:45:14 +0100 Subject: [PATCH 4/7] Ignore relative grouped cutoffs that are not a positive number of days A cutoff like -7d or now-0d is rejected by the backend. It no longer becomes a local cutoff in the future or at the current time, so the server decides. --- .../event/handler/grouped/internal/GroupedChannelFilter.kt | 1 + .../handler/grouped/internal/GroupedChannelFilterTest.kt | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt index 328a6ad92941..5686f81a3a94 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt @@ -118,4 +118,5 @@ private fun relativeCutoffOrNull(value: String, now: Date): Date? = value.remove .takeIf { it.endsWith(RELATIVE_DATE_DAYS_SUFFIX) } ?.removeSuffix(RELATIVE_DATE_DAYS_SUFFIX) ?.toLongOrNull() + ?.takeIf { days -> days > 0 } ?.let { days -> Date(now.time - TimeUnit.DAYS.toMillis(days)) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt index a210ae566cce..77a13393a721 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt @@ -63,6 +63,13 @@ internal class GroupedChannelFilterTest { assertFalse(Filters.greaterThanEquals("query_timestamp", daysAgo(7)).matchesGroupedChannel("ended", daysAgo(8), now)) } + @Test + fun `relative cutoff that is not a positive number of days matches`() { + listOf("-7d", "now--7d", "0d", "now-0d").forEach { cutoff -> + assertTrue(Filters.greaterThanEquals("query_timestamp", cutoff).matchesGroupedChannel("ended", daysAgo(8), now)) + } + } + @Test fun `full timestamp cutoff is supported`() { val filter = Filters.greaterThanEquals("query_timestamp", "2027-01-08T08:00:00.000Z") From 46a87387113929a7e7a6ee35302615f513021c28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 16 Oct 2026 08:53:16 +0100 Subject: [PATCH 5/7] Watch a partially updated channel only when it can show within the loaded channels A partial update on an old channel with no local copy no longer makes every member client watch it and add it below the loaded pages. Grouped queries watch it when the group has no more pages, or when its known sort key places it at or above the last loaded channel. --- .../internal/GroupAwareChatEventHandler.kt | 2 +- .../internal/QueryChannelsLogic.kt | 22 +- .../internal/QueryChannelsLogicGroupedTest.kt | 29 --- .../QueryChannelsLogicGroupedWatchTest.kt | 190 ++++++++++++++++++ 4 files changed, 212 insertions(+), 31 deletions(-) create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index 48bfcd1e40e0..c34a1ef3c487 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -122,7 +122,7 @@ internal class GroupAwareChatEventHandler( * * 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. + * channel is dropped after the watch. The query skips the watch when the channel sorts below its loaded pages. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index dcd388493236..2a1e8c3a0aa3 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -21,6 +21,7 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryChannelsResult 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.plugin.QueryChannelsIdentifier @@ -38,6 +39,7 @@ import io.getstream.chat.android.state.event.handler.grouped.internal.isUnreadIn import io.getstream.chat.android.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import io.getstream.log.taggedLogger import io.getstream.result.Result import kotlinx.coroutines.async @@ -667,10 +669,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-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt index f66a0599f0f9..fe4febcedb35 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedTest.kt @@ -17,7 +17,6 @@ package io.getstream.chat.android.state.plugin.logic.querychannels.internal import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.client.channel.ChannelClient import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest @@ -35,7 +34,6 @@ import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryCo import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.state.plugin.state.querychannels.internal.QueryChannelsMutableState import io.getstream.chat.android.test.TestCoroutineRule -import io.getstream.chat.android.test.asCall import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -846,33 +844,6 @@ internal class QueryChannelsLogicGroupedTest { // endregion - @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" } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt new file mode 100644 index 000000000000..8e022f507cbd --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/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.state.plugin.logic.querychannels.internal + +import io.getstream.chat.android.client.ChatClient +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.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +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") + } +} From be46d0617f0b7bfc750198c4faeddc30946bf917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 16 Oct 2026 09:57:44 +0100 Subject: [PATCH 6/7] Run the JUnit 5 setup in the channel media attachments preview tests The tests used JUnit 4 @Before and @After with JUnit 5 tests, so the test Main dispatcher was never set and the share tests depended on test order. Also drop an unused JUnit 4 rule from the grouped watch tests. --- .../ChannelMediaAttachmentsPreviewViewModelTest.kt | 8 ++++---- .../internal/QueryChannelsLogicGroupedWatchTest.kt | 5 ----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModelTest.kt index 165d77bb9f53..804f745f24f6 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModelTest.kt @@ -34,12 +34,12 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertInstanceOf import org.mockito.kotlin.doReturn @@ -49,12 +49,12 @@ import org.mockito.kotlin.whenever internal class ChannelMediaAttachmentsPreviewViewModelTest { - @Before + @BeforeEach fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) } - @After + @AfterEach fun tearDown() { Dispatchers.resetMain() } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt index 8e022f507cbd..81b1bc6d0afe 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -28,11 +28,9 @@ import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomString import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState -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 af8d75576073436bb22629c4702bb3cc16d5d249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 16 Oct 2026 10:26:30 +0100 Subject: [PATCH 7/7] 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 or image and missing channel data. --- .../client/api2/mapping/DomainMappingTest.kt | 21 ++++++++ .../internal/GroupedPartialUpdateWatch.kt | 54 +++++++++++++++++++ .../internal/QueryChannelsLogic.kt | 25 ++------- .../internal/ChannelEventHandlerTest.kt | 2 + 4 files changed, 82 insertions(+), 20 deletions(-) create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt 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 bd77a02e3a35..f9d90b565c5b 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 @@ -284,6 +284,27 @@ internal class DomainMappingTest { assertEquals("Channel name (transformed)", result.name) } + @Test + fun `Partially updated channel without name or image maps them to empty values`() { + val dto = DownstreamChannelPartialUpdateDto( + cid = "messaging:123", + id = "123", + type = "messaging", + name = null, + image = null, + created_at = null, + updated_at = null, + frozen = false, + disabled = false, + extraData = emptyMap(), + ) + + val result = with(Fixture().get()) { dto.toDomain() } + + assertEquals("", result.name) + assertEquals("", result.image) + } + @Test fun `DownstreamChannelDto is correctly mapped to Channel`() { val downstreamChannelDto = randomDownstreamChannelDto() diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt new file mode 100644 index 000000000000..66b6936d50c8 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/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.state.plugin.logic.querychannels.internal + +import io.getstream.chat.android.client.events.ChatEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate + +/** + * 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-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 2a1e8c3a0aa3..2e29a05433f8 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -21,7 +21,6 @@ import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryChannelsResult 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.plugin.QueryChannelsIdentifier @@ -39,7 +38,6 @@ import io.getstream.chat.android.state.event.handler.grouped.internal.isUnreadIn import io.getstream.chat.android.state.model.querychannels.pagination.internal.toOfflinePaginationRequest import io.getstream.chat.android.state.plugin.state.querychannels.ChannelsStateData import io.getstream.chat.android.state.plugin.state.querychannels.GroupedQueryConfig -import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import io.getstream.log.taggedLogger import io.getstream.result.Result import kotlinx.coroutines.async @@ -670,27 +668,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-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt index 41463d724f50..d8dfa212bbee 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt @@ -92,6 +92,7 @@ import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMuta import io.getstream.chat.android.state.utils.internal.mergePartialUpdate 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 @@ -555,6 +556,7 @@ internal class ChannelEventHandlerTest { val update = argumentCaptor<(ChannelData?) -> ChannelData?>() verify(stateLogic).updateChannelData(update.capture()) assertEquals(currentData.mergePartialUpdate(partial), update.firstValue(currentData)) + assertNull(update.firstValue(null)) } @Test