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