diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index 0b73bbc6c882..038c835dc3c6 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -38,6 +38,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent @@ -599,6 +600,22 @@ public fun randomNewMessageEvent( ) } +public fun randomNotificationChannelPartialUpdatedEvent( + createdAt: Date = randomDate(), + cid: String = randomCID(), + channelType: String = randomString(), + channelId: String = randomString(), + channel: Channel = randomChannel(), +): NotificationChannelPartialUpdatedEvent = NotificationChannelPartialUpdatedEvent( + type = EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED, + createdAt = createdAt, + rawCreatedAt = streamFormatter.format(createdAt), + cid = cid, + channelType = channelType, + channelId = channelId, + channel = channel, +) + public fun randomNotificationChannelTruncatedEvent( createdAt: Date = randomDate(), cid: String = randomCID(), diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index d80752b33282..abab38039fcd 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -1954,6 +1954,29 @@ public final class io/getstream/chat/android/client/events/NotificationChannelMu public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/client/events/NotificationChannelPartialUpdatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Date; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Lio/getstream/chat/android/models/Channel; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;)Lio/getstream/chat/android/client/events/NotificationChannelPartialUpdatedEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/NotificationChannelPartialUpdatedEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/NotificationChannelPartialUpdatedEvent; + public fun equals (Ljava/lang/Object;)Z + public fun getChannel ()Lio/getstream/chat/android/models/Channel; + public fun getChannelId ()Ljava/lang/String; + public fun getChannelType ()Ljava/lang/String; + public fun getCid ()Ljava/lang/String; + public fun getCreatedAt ()Ljava/util/Date; + public fun getRawCreatedAt ()Ljava/lang/String; + public fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/client/events/NotificationChannelTruncatedEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/HasChannel, io/getstream/chat/android/client/events/HasGroupedUnreadChannels, io/getstream/chat/android/client/events/HasUnreadCounts { public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;)V public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;IILjava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index f5e7b26b8589..8fa085b69ce8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.api2.model.dto.DeliveryReceiptsDto import io.getstream.chat.android.client.api2.model.dto.DeviceDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelMuteDto +import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelPartialUpdateDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelUserRead import io.getstream.chat.android.client.api2.model.dto.DownstreamDraftDto import io.getstream.chat.android.client.api2.model.dto.DownstreamFlagDto @@ -193,6 +194,24 @@ internal class DomainMapping( ).syncUnreadCountWithReads(currentUserIdProvider()) .let(channelTransformer::transform) + /** + * Transforms [DownstreamChannelPartialUpdateDto] into a [Channel] holding only the fields the DTO carries. + * Like for a full channel, the server's `disabled` flag is kept in [Channel.extraData] and the channel + * transformer is applied, so the fields it carries replace local values that were transformed too. + */ + internal fun DownstreamChannelPartialUpdateDto.toDomain(): Channel = + Channel( + id = id, + type = type, + name = name ?: "", + image = image ?: "", + frozen = frozen, + createdAt = created_at, + updatedAt = updated_at, + team = team, + extraData = (extraData + (CHANNEL_DISABLED_FIELD to disabled)).toMutableMap(), + ).let(channelTransformer::transform) + internal fun DownstreamChannelDto.toChannelInfo(): ChannelInfo = ChannelInfo( cid = cid, @@ -923,5 +942,6 @@ internal class DomainMapping( private companion object { private const val FIELD_LAST_MESSAGE_AT = "last_message_at" private const val FIELD_LAST_UPDATED = "last_updated" + private const val CHANNEL_DISABLED_FIELD = "disabled" } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index 2a30dae7f77e..5638ef8fa38f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -53,6 +53,7 @@ import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelMutesUpdatedEventDto +import io.getstream.chat.android.client.api2.model.dto.NotificationChannelPartialUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelTruncatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteAcceptedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteRejectedEventDto @@ -120,6 +121,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -197,6 +199,7 @@ internal class EventMapping( is NotificationAddedToChannelEventDto -> toDomain() is NotificationChannelDeletedEventDto -> toDomain() is NotificationChannelMutesUpdatedEventDto -> toDomain() + is NotificationChannelPartialUpdatedEventDto -> toDomain() is NotificationChannelTruncatedEventDto -> toDomain() is NotificationInviteAcceptedEventDto -> toDomain() is NotificationInviteRejectedEventDto -> toDomain() @@ -540,6 +543,22 @@ internal class EventMapping( ) } + /** + * Transforms [NotificationChannelPartialUpdatedEventDto] to [NotificationChannelPartialUpdatedEvent]. + */ + private fun NotificationChannelPartialUpdatedEventDto.toDomain(): NotificationChannelPartialUpdatedEvent = + with(domainMapping) { + NotificationChannelPartialUpdatedEvent( + type = type, + createdAt = created_at.date, + rawCreatedAt = created_at.rawDate, + cid = cid, + channelType = channel_type, + channelId = channel_id, + channel = channel.toDomain(), + ) + } + /** * Transforms [NotificationChannelTruncatedEventDto] to [NotificationChannelTruncatedEvent]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt index c72ace9f6902..613543a16958 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelDtos.kt @@ -49,6 +49,25 @@ internal data class DownstreamChannelDto( val extraData: Map, ) : ExtraDataDto +/** + * Model holding the channel fields delivered with `notification.channel_partial_updated` events. Like in + * [DownstreamChannelDto], the custom fields are at the top level of the channel and are collected in [extraData]. + */ +@JsonClass(generateAdapter = true) +internal data class DownstreamChannelPartialUpdateDto( + val cid: String, + val id: String, + val type: String, + val name: String?, + val image: String?, + val created_at: Date?, + val updated_at: Date?, + val frozen: Boolean, + val disabled: Boolean, + val team: String = "", + val extraData: Map, +) : ExtraDataDto + /** * Model holding custom channel fields delivered with `message.new` events. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index d214cfbc5f2f..2137b51cbbcb 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -260,6 +260,16 @@ internal data class NotificationChannelTruncatedEventDto( val grouped_unread_channels: Map? = null, ) : ChatEventDto() +@JsonClass(generateAdapter = true) +internal data class NotificationChannelPartialUpdatedEventDto( + val type: String, + val created_at: ExactDate, + val cid: String, + val channel_type: String, + val channel_id: String, + val channel: DownstreamChannelPartialUpdateDto, +) : ChatEventDto() + @JsonClass(generateAdapter = true) internal data class NotificationInviteAcceptedEventDto( val type: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt index 788b390967cd..b8098ea2ec55 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt @@ -59,6 +59,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -277,6 +278,7 @@ public class ChannelClient internal constructor( is NewMessageEvent -> event.cid == cid is NotificationAddedToChannelEvent -> event.cid == cid is NotificationChannelDeletedEvent -> event.cid == cid + is NotificationChannelPartialUpdatedEvent -> event.cid == cid is NotificationChannelTruncatedEvent -> event.cid == cid is NotificationInviteAcceptedEvent -> event.cid == cid is NotificationInviteRejectedEvent -> event.cid == cid diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index 083ede23e4c4..1cb84f6eec94 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -417,6 +417,25 @@ public data class NotificationChannelMutesUpdatedEvent( override val me: User, ) : ChatEvent(), HasOwnUser +/** + * Triggered when a channel is partially updated, for members of the channel who are not watching it. Members who + * are watching it receive [ChannelUpdatedEvent] instead. + * + * The [channel] carries only part of the channel data: [Channel.id], [Channel.type], [Channel.name], [Channel.image], + * [Channel.frozen], [Channel.team], [Channel.createdAt], [Channel.updatedAt] and [Channel.extraData] (the complete + * custom data, plus the `disabled` flag). Other fields, such as members, messages, reads, config and + * [Channel.lastMessageAt], keep their default values. + */ +public data class NotificationChannelPartialUpdatedEvent( + override val type: String, + override val createdAt: Date, + override val rawCreatedAt: String, + override val cid: String, + override val channelType: String, + override val channelId: String, + override val channel: Channel, +) : CidEvent(), HasChannel + /** * Triggered when a channels' history is truncated */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt index 1c4a441ef379..e89b8b79b19b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/MoshiChatParser.kt @@ -34,6 +34,7 @@ import io.getstream.chat.android.client.parser2.adapters.AttachmentDtoAdapter import io.getstream.chat.android.client.parser2.adapters.CreatePollRequestAdapter import io.getstream.chat.android.client.parser2.adapters.DateAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamChannelDtoAdapter +import io.getstream.chat.android.client.parser2.adapters.DownstreamChannelPartialUpdateDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamMemberDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamMessageDtoAdapter import io.getstream.chat.android.client.parser2.adapters.DownstreamModerationDetailsDtoAdapter @@ -71,6 +72,7 @@ internal class MoshiChatParser( .add(DownstreamModerationDetailsDtoAdapter) .add(UpstreamMessageDtoAdapter) .add(DownstreamChannelDtoAdapter) + .add(DownstreamChannelPartialUpdateDtoAdapter) .add(AttachmentDtoAdapter) .add(DownstreamReactionDtoAdapter) .add(UpstreamReactionDtoAdapter) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt index 088ed3212a4f..0937a1f12c8a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/DownstreamChannelDtoAdapter.kt @@ -22,6 +22,7 @@ import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter import com.squareup.moshi.ToJson import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelDto +import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelPartialUpdateDto internal object DownstreamChannelDtoAdapter : CustomObjectDtoAdapter(DownstreamChannelDto::class) { @@ -37,3 +38,19 @@ internal object DownstreamChannelDtoAdapter : @Suppress("UNUSED_PARAMETER") fun toJson(jsonWriter: JsonWriter, value: DownstreamChannelDto): Unit = error("Can't convert this to Json") } + +internal object DownstreamChannelPartialUpdateDtoAdapter : + CustomObjectDtoAdapter(DownstreamChannelPartialUpdateDto::class) { + + @FromJson + fun fromJson( + jsonReader: JsonReader, + mapAdapter: JsonAdapter>, + channelAdapter: JsonAdapter, + ): DownstreamChannelPartialUpdateDto? = parseWithExtraData(jsonReader, mapAdapter, channelAdapter) + + @ToJson + @Suppress("UNUSED_PARAMETER") + fun toJson(jsonWriter: JsonWriter, value: DownstreamChannelPartialUpdateDto): Unit = + error("Can't convert this to Json") +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt index eab4fac67898..b27c8c892e0c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt @@ -55,6 +55,7 @@ import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelMutesUpdatedEventDto +import io.getstream.chat.android.client.api2.model.dto.NotificationChannelPartialUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelTruncatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteAcceptedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteRejectedEventDto @@ -151,6 +152,8 @@ internal class EventDtoAdapter( private val notificationChannelDeletedEventAdapter = moshi.adapter(NotificationChannelDeletedEventDto::class.java) private val notificationChannelTruncatedEventAdapter = moshi.adapter(NotificationChannelTruncatedEventDto::class.java) + private val notificationChannelPartialUpdatedEventAdapter = + moshi.adapter(NotificationChannelPartialUpdatedEventDto::class.java) private val userPresenceChangedEventAdapter = moshi.adapter(UserPresenceChangedEventDto::class.java) private val userUpdatedEventAdapter = moshi.adapter(UserUpdatedEventDto::class.java) private val userDeletedEventAdapter = moshi.adapter(UserDeletedEventDto::class.java) @@ -233,6 +236,7 @@ internal class EventDtoAdapter( EventType.NOTIFICATION_CHANNEL_MUTES_UPDATED -> notificationChannelMutesUpdatedEventAdapter EventType.NOTIFICATION_CHANNEL_DELETED -> notificationChannelDeletedEventAdapter EventType.NOTIFICATION_CHANNEL_TRUNCATED -> notificationChannelTruncatedEventAdapter + EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED -> notificationChannelPartialUpdatedEventAdapter EventType.USER_PRESENCE_CHANGED -> userPresenceChangedEventAdapter EventType.USER_UPDATED -> userUpdatedEventAdapter EventType.USER_DELETED -> userDeletedEventAdapter diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt index f2a62c2d693a..a87d9de89072 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt @@ -237,6 +237,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 f9b8c4b5885a..f9d90b565c5b 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -61,6 +61,7 @@ import io.getstream.chat.android.client.Mother.randomUnreadCountByTeamDto import io.getstream.chat.android.client.Mother.randomUnreadDto import io.getstream.chat.android.client.Mother.randomUnreadThreadDto import io.getstream.chat.android.client.api2.mapping.DomainMappingTest.Companion.toSortDomainArguments +import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelPartialUpdateDto import io.getstream.chat.android.client.api2.model.dto.DownstreamThreadParticipantDto import io.getstream.chat.android.client.api2.model.response.MessageResponse import io.getstream.chat.android.models.Answer @@ -258,6 +259,52 @@ internal class DomainMappingTest { assertEquals(transformedChannel, result) } + @Test + fun `Partially updated channel should be transformed after it is mapped`() { + val channelTransformer = ChannelTransformer { it.copy(name = "${it.name} (transformed)") } + val dto = DownstreamChannelPartialUpdateDto( + cid = "messaging:123", + id = "123", + type = "messaging", + name = "Channel name", + image = null, + created_at = null, + updated_at = null, + frozen = false, + disabled = false, + extraData = emptyMap(), + ) + + val sut = Fixture() + .withChannelTransformer(channelTransformer) + .get() + + val result = with(sut) { dto.toDomain() } + + assertEquals("Channel name (transformed)", result.name) + } + + @Test + fun `Partially updated channel without name or image maps them to empty values`() { + val dto = DownstreamChannelPartialUpdateDto( + cid = "messaging:123", + id = "123", + type = "messaging", + name = null, + image = null, + created_at = null, + updated_at = null, + frozen = false, + disabled = false, + extraData = emptyMap(), + ) + + val result = with(Fixture().get()) { dto.toDomain() } + + assertEquals("", result.name) + assertEquals("", result.image) + } + @Test fun `DownstreamChannelDto is correctly mapped to Channel`() { val downstreamChannelDto = randomDownstreamChannelDto() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index f658b38df10c..f5654f196667 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -34,6 +34,7 @@ import io.getstream.chat.android.client.api2.model.dto.ConnectingEventDto import io.getstream.chat.android.client.api2.model.dto.ConnectionErrorEventDto import io.getstream.chat.android.client.api2.model.dto.DisconnectedEventDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelCustomDto +import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelPartialUpdateDto import io.getstream.chat.android.client.api2.model.dto.DraftMessageDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.DraftMessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.ErrorEventDto @@ -52,6 +53,7 @@ import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationAddedToChannelEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelDeletedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelMutesUpdatedEventDto +import io.getstream.chat.android.client.api2.model.dto.NotificationChannelPartialUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationChannelTruncatedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteAcceptedEventDto import io.getstream.chat.android.client.api2.model.dto.NotificationInviteRejectedEventDto @@ -119,6 +121,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -151,6 +154,7 @@ import io.getstream.chat.android.client.events.UserUpdatedEvent import io.getstream.chat.android.client.events.VoteCastedEvent import io.getstream.chat.android.client.events.VoteChangedEvent import io.getstream.chat.android.client.events.VoteRemovedEvent +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelInfo import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.NoOpChannelTransformer @@ -483,6 +487,27 @@ internal object EventMappingTestArguments { me = USER, ) + private val notificationChannelPartialUpdatedDto = NotificationChannelPartialUpdatedEventDto( + type = EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED, + created_at = EXACT_DATE, + cid = CID, + channel_type = CHANNEL_TYPE, + channel_id = CHANNEL_ID, + channel = DownstreamChannelPartialUpdateDto( + cid = CID, + id = CHANNEL_ID, + type = CHANNEL_TYPE, + name = "Channel name", + image = "image.png", + created_at = DATE, + updated_at = DATE, + frozen = true, + disabled = true, + team = "team1", + extraData = mapOf("group" to "current"), + ), + ) + private val notificationChannelTruncatedDto = NotificationChannelTruncatedEventDto( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, created_at = EXACT_DATE, @@ -1139,6 +1164,26 @@ internal object EventMappingTestArguments { me = with(domainMapping) { notificationChannelMutesUpdatesDto.me.toDomain() }, ) + private val notificationChannelPartialUpdated = NotificationChannelPartialUpdatedEvent( + type = notificationChannelPartialUpdatedDto.type, + createdAt = notificationChannelPartialUpdatedDto.created_at.date, + rawCreatedAt = notificationChannelPartialUpdatedDto.created_at.rawDate, + cid = notificationChannelPartialUpdatedDto.cid, + channelType = notificationChannelPartialUpdatedDto.channel_type, + channelId = notificationChannelPartialUpdatedDto.channel_id, + channel = Channel( + id = CHANNEL_ID, + type = CHANNEL_TYPE, + name = "Channel name", + image = "image.png", + frozen = true, + createdAt = DATE, + updatedAt = DATE, + team = "team1", + extraData = mapOf("group" to "current", "disabled" to true), + ), + ) + private val notificationChannelTruncated = NotificationChannelTruncatedEvent( type = notificationChannelTruncatedDto.type, createdAt = notificationChannelTruncatedDto.created_at.date, @@ -1589,6 +1634,7 @@ internal object EventMappingTestArguments { Arguments.of(notificationAddedToChannelDto, notificationAddedToChannel), Arguments.of(notificationChannelDeletedDto, notificationChannelDeleted), Arguments.of(notificationChannelMutesUpdatesDto, notificationChannelMutesUpdates), + Arguments.of(notificationChannelPartialUpdatedDto, notificationChannelPartialUpdated), Arguments.of(notificationChannelTruncatedDto, notificationChannelTruncated), Arguments.of(notificationInviteAcceptedDto, notificationInviteAccepted), Arguments.of(notificationInviteRejectedDto, notificationInviteRejected), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index 7a5d518537b2..c13051dcd9d1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt @@ -50,6 +50,7 @@ import io.getstream.chat.android.client.createNewMessageWithoutUnreadCountsEvent import io.getstream.chat.android.client.createNotificationAddedToChannelEventStringJson import io.getstream.chat.android.client.createNotificationChannelDeletedEventStringJson import io.getstream.chat.android.client.createNotificationChannelMutesUpdatedEventStringJson +import io.getstream.chat.android.client.createNotificationChannelPartialUpdatedEventStringJson import io.getstream.chat.android.client.createNotificationChannelTruncatedEventStringJson import io.getstream.chat.android.client.createNotificationInviteAcceptedEventStringJson import io.getstream.chat.android.client.createNotificationInviteRejectedEventStringJson @@ -115,6 +116,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -544,6 +546,25 @@ internal object EventArguments { channel = channel, groupedUnreadChannels = groupedUnreadChannels, ) + private val notificationChannelPartialUpdatedEvent = NotificationChannelPartialUpdatedEvent( + type = EventType.NOTIFICATION_CHANNEL_PARTIAL_UPDATED, + createdAt = date, + rawCreatedAt = streamDateFormatter.format(date), + cid = cid, + channelType = channelType, + channelId = channelId, + channel = Channel( + id = channelId, + type = channelType, + name = "Channel name", + image = "https://example.com/channel.png", + frozen = true, + createdAt = date, + updatedAt = date, + team = "team1", + extraData = mutableMapOf("group" to "current", "disabled" to false), + ), + ) private val notificationChannelTruncatedEvent = NotificationChannelTruncatedEvent( type = EventType.NOTIFICATION_CHANNEL_TRUNCATED, createdAt = date, @@ -1049,6 +1070,7 @@ internal object EventArguments { messageUpdatedEvent, notificationAddedToChannelEvent, notificationChannelDeletedEvent, + notificationChannelPartialUpdatedEvent, notificationChannelTruncatedEvent, notificationInviteAcceptedEvent, notificationInviteRejectedEvent, @@ -1116,6 +1138,7 @@ internal object EventArguments { Arguments.of(createMessageUpdatedEventStringJson(), messageUpdatedEvent), Arguments.of(createNotificationAddedToChannelEventStringJson(), notificationAddedToChannelEvent), Arguments.of(createNotificationChannelDeletedEventStringJson(), notificationChannelDeletedEvent), + Arguments.of(createNotificationChannelPartialUpdatedEventStringJson(), notificationChannelPartialUpdatedEvent), Arguments.of(createNotificationChannelTruncatedEventStringJson(), notificationChannelTruncatedEvent), Arguments.of(createNotificationInviteAcceptedEventStringJson(), notificationInviteAcceptedEvent), Arguments.of(createNotificationInviteRejectedEventStringJson(), notificationInviteRejectedEvent), diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModelTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModelTest.kt index 165d77bb9f53..804f745f24f6 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModelTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/viewmodel/channel/ChannelMediaAttachmentsPreviewViewModelTest.kt @@ -34,12 +34,12 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertInstanceOf import org.mockito.kotlin.doReturn @@ -49,12 +49,12 @@ import org.mockito.kotlin.whenever internal class ChannelMediaAttachmentsPreviewViewModelTest { - @Before + @BeforeEach fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher()) } - @After + @AfterEach fun tearDown() { Dispatchers.resetMain() } diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index d225ab8f1cdd..b6b2d3aa35e8 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -941,6 +941,7 @@ public final class io/getstream/chat/android/models/EventType { public static final field NOTIFICATION_ADDED_TO_CHANNEL Ljava/lang/String; public static final field NOTIFICATION_CHANNEL_DELETED Ljava/lang/String; public static final field NOTIFICATION_CHANNEL_MUTES_UPDATED Ljava/lang/String; + public static final field NOTIFICATION_CHANNEL_PARTIAL_UPDATED Ljava/lang/String; public static final field NOTIFICATION_CHANNEL_TRUNCATED Ljava/lang/String; public static final field NOTIFICATION_INVITED Ljava/lang/String; public static final field NOTIFICATION_INVITE_ACCEPTED Ljava/lang/String; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt index 4200a1492274..0e180974f981 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt @@ -57,6 +57,7 @@ public object EventType { public const val NOTIFICATION_THREAD_MESSAGE_NEW: String = "notification.thread_message_new" public const val NOTIFICATION_CHANNEL_TRUNCATED: String = "notification.channel_truncated" public const val NOTIFICATION_CHANNEL_DELETED: String = "notification.channel_deleted" + public const val NOTIFICATION_CHANNEL_PARTIAL_UPDATED: String = "notification.channel_partial_updated" public const val NOTIFICATION_MARK_READ: String = "notification.mark_read" public const val NOTIFICATION_MARK_UNREAD: String = "notification.mark_unread" public const val NOTIFICATION_INVITED: String = "notification.invited" diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index a1eee62610e6..c34a1ef3c487 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.client.events.CidEvent import io.getstream.chat.android.client.events.HasChannel import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationMessageNewEvent import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.models.Channel @@ -32,6 +33,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.state.event.handler.chat.DefaultChatEventHandler import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import kotlinx.coroutines.flow.StateFlow import java.util.Date @@ -65,6 +67,8 @@ internal class GroupAwareChatEventHandler( is ChannelUpdatedByUserEvent, -> routeByGroup((event as HasChannel).channel, cachedChannel, filter) + is NotificationChannelPartialUpdatedEvent -> routePartialUpdate(event, cachedChannel, filter) + is NotificationAddedToChannelEvent, is NotificationMessageNewEvent, is ChannelVisibleEvent, @@ -111,10 +115,46 @@ internal class GroupAwareChatEventHandler( } } + /** + * Same checks as [routeByGroup], against [cachedChannel] with the partial update applied. The event's channel + * lacks data such as members and the last message time, so a channel that is not in the list yet is watched to + * get its full data instead of being added from the event. + * + * Without [cachedChannel], only the group is checked: the last activity is unknown, so the cutoff is left to the + * list, which checks the watched channel before showing it. The event is only sent to members, and a hidden + * channel is dropped after the watch. The query skips the watch when the channel sorts below its loaded pages. + */ + private fun routePartialUpdate( + event: NotificationChannelPartialUpdatedEvent, + cachedChannel: Channel?, + filter: FilterObject, + ): EventHandlingResult { + val belongsHere = if (cachedChannel == null) { + groupMatches(event.channel, filter) + } else { + channelBelongsHere(cachedChannel.mergePartialUpdate(event.channel), filter) && + isCurrentUserMember(cachedChannel) && + cachedChannel.hidden != true + } + val isInList = channels.value?.containsKey(event.cid) == true + return when { + belongsHere && !isInList -> EventHandlingResult.WatchAndAdd(event.cid) + !belongsHere && isInList -> EventHandlingResult.Remove(event.cid) + else -> EventHandlingResult.Skip + } + } + private fun channelBelongsHere(channel: Channel, filter: FilterObject): Boolean = resolver.resolve(channel).contains(groupKey) && filter.matchesGroupedChannel(group = channel.group, queryTimestamp = channel.queryTimestamp, now = now()) + /** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown. */ + private fun groupMatches(channel: Channel, filter: FilterObject): Boolean { + val checkedAt = now() + return resolver.resolve(channel).contains(groupKey) && + filter.matchesGroupedChannel(group = channel.group, queryTimestamp = checkedAt, now = checkedAt) + } + /** The channel with [newMessage] counted in its last message time, as the channel state counts it. */ private fun Channel.withNewMessage(newMessage: Message?): Channel = when (newMessage) { null -> this diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt index 328a6ad92941..5686f81a3a94 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilter.kt @@ -118,4 +118,5 @@ private fun relativeCutoffOrNull(value: String, now: Date): Date? = value.remove .takeIf { it.endsWith(RELATIVE_DATE_DAYS_SUFFIX) } ?.removeSuffix(RELATIVE_DATE_DAYS_SUFFIX) ?.toLongOrNull() + ?.takeIf { days -> days > 0 } ?.let { days -> Date(now.time - TimeUnit.DAYS.toMillis(days)) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt index c9194a4698c1..69ca04415f58 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdater.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.client.events.HasGroupedUnreadChannels import io.getstream.chat.android.client.events.MarkAllReadEvent import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent @@ -106,6 +107,16 @@ internal class GroupedUnreadChannelsUpdater( event: ChannelUpdatedByUserEvent, ): Map = applyDelta(current, batchId, event.cid, event.channel) + /** + * `notification.channel_partial_updated` delta. Same semantics as the [ChannelUpdatedEvent] overload. The + * event's channel carries the custom data, so it holds the new group. + */ + fun calculateUpdatedCounts( + current: Map, + batchId: Int, + event: NotificationChannelPartialUpdatedEvent, + ): Map = applyDelta(current, batchId, event.cid, event.channel) + /** * Records that channel [cid] has been removed within [batchId] — either deleted, or the * current user is no longer a member. Later in-batch deltas for that cid are skipped. diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt index a4adeaa775a7..909a58969776 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequential.kt @@ -52,6 +52,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -123,6 +124,7 @@ import io.getstream.chat.android.state.plugin.logic.querychannels.internal.Query import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineExceptionHandler @@ -451,6 +453,10 @@ internal class EventHandlerSequential( groupedUnreadChannels = groupedUnreadChannelsUpdater .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) } + (event as? NotificationChannelPartialUpdatedEvent)?.let { e -> + groupedUnreadChannels = groupedUnreadChannelsUpdater + .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) + } (event as? HasOwnUser)?.let { modifyValuesFromUser(it.me) } (event as? HasUnreadThreadCounts)?.let { modifyUnreadThreadsCount(it) } (event as? UserUpdatedEvent) @@ -806,6 +812,12 @@ internal class EventHandlerSequential( ?.mergeChannelFromEvent(event.channel) ?: event.channel batch.addChannel(channel) } + is NotificationChannelPartialUpdatedEvent -> { + // The event's channel is incomplete, so it is never stored on its own. + batch.getCurrentChannel(event.cid) + ?.mergePartialUpdate(event.channel) + ?.let(batch::addChannel) + } is ChannelDeletedEvent -> { val channel = batch.getCurrentChannel(event.cid) ?.mergeChannelFromEvent(event.channel) ?: event.channel diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt index bcd803c8546a..d810fca392e7 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandler.kt @@ -39,6 +39,7 @@ import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationAddedToChannelEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent import io.getstream.chat.android.client.events.NotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationInviteAcceptedEvent import io.getstream.chat.android.client.events.NotificationInviteRejectedEvent @@ -72,6 +73,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageReminder import io.getstream.chat.android.state.event.handler.internal.utils.toChannelUserRead import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate /** * Class responsible for updating the local database based on channel-related events. @@ -217,6 +219,9 @@ internal class ChannelEventHandler( // Channel update events is ChannelUpdatedEvent -> stateLogic.updateChannelData(event) is ChannelUpdatedByUserEvent -> stateLogic.updateChannelData(event) + is NotificationChannelPartialUpdatedEvent -> stateLogic.updateChannelData { data -> + data?.mergePartialUpdate(event.channel) + } is ChannelHiddenEvent -> { stateLogic.setHidden(true) if (event.clearHistory) { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt new file mode 100644 index 000000000000..66b6936d50c8 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/GroupedPartialUpdateWatch.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.plugin.logic.querychannels.internal + +import io.getstream.chat.android.client.events.ChatEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate + +/** + * Turns a [EventHandlingResult.WatchAndAdd] for a partial channel update into [EventHandlingResult.Skip] when the + * channel cannot be shown in the grouped list of [state] without the pages before it. A channel below the loaded + * channels is shown when its page loads, so a partial update on an old channel does not watch it. Other results and + * events are returned unchanged. + * + * @param event The event that produced this result. + * @param cachedChannel The local copy of the event's channel, or `null` when there is none. + * @param state The state of the grouped query. + */ +internal fun EventHandlingResult.skipWatchBelowLoadedChannels( + event: ChatEvent, + cachedChannel: Channel?, + state: QueryChannelsState, +): EventHandlingResult { + if (this !is EventHandlingResult.WatchAndAdd || event !is NotificationChannelPartialUpdatedEvent) return this + val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel + return if (state.isWithinLoadedChannels(channel)) this else EventHandlingResult.Skip +} + +/** + * Whether [channel] sorts within the loaded channels: the group has no more pages, or the channel sorts at or above + * the last loaded channel. Without local data the channel has no last message time, so its position is a lower bound. + */ +private fun QueryChannelsState.isWithinLoadedChannels(channel: Channel): Boolean { + if (endOfChannels.value) return true + val lastLoaded = channels.value?.lastOrNull() ?: return true + return sort.comparator.compare(channel, lastLoaded) <= 0 +} diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt index 97e79d523a9f..2e29a05433f8 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogic.kt @@ -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) } } @@ -665,7 +667,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-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt new file mode 100644 index 000000000000..8c1269e16fbd --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdate.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.utils.internal + +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelData + +/** + * Channel response fields that the channel model does not declare, so they arrive in the extra data next to the + * custom fields. A partial update carries only the custom fields and `disabled`, so the local values of the others + * are kept. + */ +private val serverFieldsInExtraData = setOf( + "auto_translation_enabled", + "auto_translation_language", + "blocked", + "disabled", + "hidden", + "hide_messages_before", + "mute_expires_at", + "muted", + "truncated_at", + "truncated_by", +) + +/** + * Applies the fields carried by [NotificationChannelPartialUpdatedEvent.channel] to this channel and keeps all + * the others, such as members, reads and [Channel.lastMessageAt]. + */ +internal fun Channel.mergePartialUpdate(partial: Channel): Channel = copy( + name = partial.name, + image = partial.image, + frozen = partial.frozen, + team = partial.team, + createdAt = partial.createdAt, + updatedAt = partial.updatedAt, + extraData = mergePartialExtraData(current = extraData, partial = partial.extraData), +) + +/** + * Applies the fields carried by [NotificationChannelPartialUpdatedEvent.channel] to this channel data and keeps + * all the others. + */ +internal fun ChannelData.mergePartialUpdate(partial: Channel): ChannelData = copy( + name = partial.name, + image = partial.image, + frozen = partial.frozen, + team = partial.team, + createdAt = partial.createdAt, + updatedAt = partial.updatedAt, + extraData = mergePartialExtraData(current = extraData, partial = partial.extraData), +) + +// The partial update holds the complete custom data, so a custom field it lacks was removed. +private fun mergePartialExtraData(current: Map, partial: Map): Map = + current.filterKeys(serverFieldsInExtraData::contains) + partial diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt index 34c498240878..65375c78258d 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerTest.kt @@ -23,6 +23,7 @@ import io.getstream.chat.android.client.test.randomMemberAddedEvent import io.getstream.chat.android.client.test.randomMemberRemovedEvent import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters @@ -455,6 +456,209 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.Skip, result) } + @Test + fun `Given a channel in the list When a partial update moves it to another group Should remove`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "other")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a channel in the list When a partial update removes its group Should remove`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = emptyMap()) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a channel in the list When a partial update keeps it in the group Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a channel not in local state When a partial update moves it into the group Should watch and add`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a cached channel not in the list When a partial update moves it into the group Should watch and add`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "other")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update keeps it in another group Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "other")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given the user hid the channel When a partial update moves it into the group Should skip`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "other")) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = true) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a cached channel the user left When a partial update moves it into the group Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "other")) + val cachedChannel = channel.copy(membership = null, hidden = false) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a recent last message in local state When a partial update sets an old touched_at Should keep it`() { + val currentUser = randomUser() + val channel = randomChannel( + createdAt = daysAgo(30), + lastMessageAt = daysAgo(1), + extraData = mapOf("group" to "vip"), + ) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip", "touched_at" to daysAgo(20))) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a channel in a filtered group When a partial update leaves it past the cutoff Should remove`() { + val currentUser = randomUser() + val channel = randomChannel( + createdAt = daysAgo(30), + lastMessageAt = daysAgo(20), + extraData = mapOf("group" to "vip", "touched_at" to daysAgo(1)), + ) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = mapOf(channel.cid to channel), + currentUser = currentUser, + ) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip", "touched_at" to daysAgo(20))) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update touches it within the cutoff Should watch and add`() { + val channel = randomChannel(createdAt = daysAgo(30), extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip", "touched_at" to daysAgo(1))) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update moves it into a filtered group Should watch and add`() { + // The last message time is unknown without local data, so the list checks the cutoff after the watch. + val channel = randomChannel(createdAt = daysAgo(30), extraData = mapOf("group" to "other")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = null) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update moves it into a group the filter excludes Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "all", cachedChannels = emptyMap(), currentUser = randomUser()) + val event = partialUpdateOf(channel, custom = mapOf("group" to "archived")) + val filter = Filters.and(Filters.ne("group", "archived"), cutoff14Days) + + val result = handler.handleChatEvent(event, filter, cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + + /** The event as the backend sends it: only the channel's basic fields and its custom data. */ + private fun partialUpdateOf(channel: Channel, custom: Map) = + randomNotificationChannelPartialUpdatedEvent( + cid = channel.cid, + channelType = channel.type, + channelId = channel.id, + channel = Channel( + id = channel.id, + type = channel.type, + createdAt = channel.createdAt, + updatedAt = channel.updatedAt, + extraData = custom + ("disabled" to false), + ), + ) + private fun channelMessage(createdAt: Date) = randomMessage( type = "regular", createdAt = createdAt, diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt index a210ae566cce..77a13393a721 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedChannelFilterTest.kt @@ -63,6 +63,13 @@ internal class GroupedChannelFilterTest { assertFalse(Filters.greaterThanEquals("query_timestamp", daysAgo(7)).matchesGroupedChannel("ended", daysAgo(8), now)) } + @Test + fun `relative cutoff that is not a positive number of days matches`() { + listOf("-7d", "now--7d", "0d", "now-0d").forEach { cutoff -> + assertTrue(Filters.greaterThanEquals("query_timestamp", cutoff).matchesGroupedChannel("ended", daysAgo(8), now)) + } + } + @Test fun `full timestamp cutoff is supported`() { val filter = Filters.greaterThanEquals("query_timestamp", "2027-01-08T08:00:00.000Z") diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt index 96857addfaa4..3cb74a6eb3f0 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupedUnreadChannelsUpdaterTest.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.client.events.HasGroupedUnreadChannels import io.getstream.chat.android.client.events.MarkAllReadEvent import io.getstream.chat.android.client.events.NewMessageEvent import io.getstream.chat.android.client.events.NotificationChannelDeletedEvent +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.events.NotificationChannelTruncatedEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent @@ -201,6 +202,17 @@ internal class GroupedUnreadChannelsUpdaterTest { assertEquals(mapOf("a" to 2, "b" to 2), next) } + @Test + fun `NotificationChannelPartialUpdatedEvent applies the same migration semantics`() { + seedActiveChannel(oldGroup = "a", unreadMessages = 2) + val current = mapOf("a" to 3, "b" to 1) + val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) + + assertEquals(mapOf("a" to 2, "b" to 2), next) + } + // endregion // region Same-batch dedup @@ -508,6 +520,17 @@ internal class GroupedUnreadChannelsUpdaterTest { message = null, ) + private fun partialUpdatedEvent(partialChannel: Channel): NotificationChannelPartialUpdatedEvent = + NotificationChannelPartialUpdatedEvent( + type = "notification.channel_partial_updated", + createdAt = Date(), + rawCreatedAt = "", + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + channel = partialChannel, + ) + private fun hasGroupedUnreadChannels(map: Map?): HasGroupedUnreadChannels = notificationMarkReadEvent(map = map) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt index 738139003dac..5a765ddcff45 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -31,6 +31,7 @@ import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent import io.getstream.chat.android.client.test.randomNotificationChannelDeletedEvent import io.getstream.chat.android.client.test.randomNotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationChannelTruncatedEvent import io.getstream.chat.android.client.test.randomNotificationMarkReadEvent import io.getstream.chat.android.client.test.randomNotificationMarkUnreadEvent @@ -39,6 +40,7 @@ import io.getstream.chat.android.client.test.randomNotificationMutesUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationRemovedFromChannelEvent import io.getstream.chat.android.client.test.randomPollDeletedEvent import io.getstream.chat.android.client.utils.observable.Disposable +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.ChannelMute import io.getstream.chat.android.models.Location @@ -282,6 +284,74 @@ internal class EventHandlerSequentialTest { mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) } + @Test + fun `NotificationChannelPartialUpdatedEvent migrates grouped unread count when group field changes`() = runTest { + val cid = "messaging:channel-id" + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + setGroupedUnreadChannels(mapOf("a" to 1, "b" to 0)) + } + val partialChannel = Channel(id = "channel-id", type = "messaging", extraData = mapOf("group" to "b")) + val handler = Fixture() + .withCurrentUser(currentUser) + .withMutableGlobalState(mutableGlobalState) + .withActiveChannel( + channelType = "messaging", + channelId = "channel-id", + extraData = mapOf("group" to "a"), + unreadMessages = 1, + ) + .get(this) + + handler.handleEvents(randomNotificationChannelPartialUpdatedEvent(cid = cid, channel = partialChannel)) + + mutableGlobalState.groupedUnreadChannels.value `should be equal to` mapOf("a" to 0, "b" to 1) + } + + @Test + fun `When handling NotificationChannelPartialUpdatedEvent, The stored channel should get the partial update`() = + runTest { + val storedChannel = randomChannel(members = listOf(randomMember()), extraData = mapOf("group" to "a")) + val partialChannel = Channel( + id = storedChannel.id, + type = storedChannel.type, + extraData = mapOf("group" to "b"), + ) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn listOf(storedChannel) + whenever(repos.selectThreads(any())) doReturn emptyList() + val handler = Fixture() + .withRepositoryFacade(repos) + .get(this) + + handler.handleEvents( + randomNotificationChannelPartialUpdatedEvent(cid = storedChannel.cid, channel = partialChannel), + ) + + val inserted = argumentCaptor>() + verify(repos).insertChannels(inserted.capture()) + val storedResult = inserted.firstValue.single() + storedResult.members `should be equal to` storedChannel.members + storedResult.extraData `should be equal to` mapOf("group" to "b") + } + + @Test + fun `When handling NotificationChannelPartialUpdatedEvent, A channel that is not stored should not be inserted`() = + runTest { + val event = randomNotificationChannelPartialUpdatedEvent() + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn emptyList() + whenever(repos.selectThreads(any())) doReturn emptyList() + val handler = Fixture() + .withRepositoryFacade(repos) + .get(this) + + handler.handleEvents(event) + + verify(repos).insertChannels(emptyList()) + } + @Test fun `Two ChannelUpdatedEvents for same cid in one batch apply the migration only once`() = runTest { val cid = "messaging:channel-id" diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt index b94a155c9e77..d8dfa212bbee 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelEventHandlerTest.kt @@ -49,6 +49,7 @@ import io.getstream.chat.android.client.test.randomNewMessageEvent import io.getstream.chat.android.client.test.randomNotificationAddedToChannelEvent import io.getstream.chat.android.client.test.randomNotificationChannelDeletedEvent import io.getstream.chat.android.client.test.randomNotificationChannelMutesUpdatedEvent +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent import io.getstream.chat.android.client.test.randomNotificationChannelTruncatedEvent import io.getstream.chat.android.client.test.randomNotificationMarkReadEvent import io.getstream.chat.android.client.test.randomNotificationMarkUnreadEvent @@ -68,7 +69,10 @@ import io.getstream.chat.android.client.test.randomUserStartWatchingEvent import io.getstream.chat.android.client.test.randomVoteCastedEvent import io.getstream.chat.android.client.test.randomVoteChangedEvent import io.getstream.chat.android.client.test.randomVoteRemovedEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelData import io.getstream.chat.android.models.EventType +import io.getstream.chat.android.models.toChannelData import io.getstream.chat.android.positiveRandomInt import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel @@ -85,11 +89,15 @@ import io.getstream.chat.android.randomThreadInfo import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.event.handler.internal.utils.toChannelUserRead import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.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 @@ -537,6 +545,20 @@ internal class ChannelEventHandlerTest { verify(stateLogic).updateChannelData(event) } + @Test + fun `When NotificationChannelPartialUpdatedEvent is handled, Then the partial update is merged into channel data`() { + val currentData = randomChannel().toChannelData() + val partial = Channel(name = randomString(), extraData = mapOf("group" to randomString())) + val event = randomNotificationChannelPartialUpdatedEvent(cid = cid, channel = partial) + + handler.handle(event) + + val update = argumentCaptor<(ChannelData?) -> ChannelData?>() + verify(stateLogic).updateChannelData(update.capture()) + assertEquals(currentData.mergePartialUpdate(partial), update.firstValue(currentData)) + 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-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt new file mode 100644 index 000000000000..81b1bc6d0afe --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/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.state.plugin.logic.querychannels.internal + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.channel.ChannelClient +import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.client.internal.state.plugin.QueryChannelsIdentifier +import io.getstream.chat.android.client.test.randomNotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.client.test.randomNotificationMessageNewEvent +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.querysort.QuerySortByField +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomString +import io.getstream.chat.android.state.event.handler.chat.EventHandlingResult +import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.test.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-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdateTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdateTest.kt new file mode 100644 index 000000000000..23daea0dab89 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelPartialUpdateTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.utils.internal + +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.toChannelData +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomDate +import io.getstream.chat.android.randomMember +import io.getstream.chat.android.randomString +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ChannelPartialUpdateTest { + + private val partial = Channel( + id = randomString(), + type = randomString(), + name = "New name", + image = "new.png", + frozen = true, + team = "team1", + createdAt = randomDate(), + updatedAt = randomDate(), + extraData = mapOf("group" to "vip", "disabled" to true), + ) + + @Test + fun `merging a partial update applies the fields it carries`() { + val local = randomChannel(frozen = false, extraData = emptyMap()) + + val merged = local.mergePartialUpdate(partial) + + assertEquals("New name", merged.name) + assertEquals("new.png", merged.image) + assertEquals(true, merged.frozen) + assertEquals("team1", merged.team) + assertEquals(partial.createdAt, merged.createdAt) + assertEquals(partial.updatedAt, merged.updatedAt) + } + + @Test + fun `merging a partial update keeps the fields it does not carry`() { + val local = randomChannel(members = listOf(randomMember()), lastMessageAt = randomDate()) + + val merged = local.mergePartialUpdate(partial) + + assertEquals(local.id, merged.id) + assertEquals(local.type, merged.type) + assertEquals(local.members, merged.members) + assertEquals(local.read, merged.read) + assertEquals(local.config, merged.config) + assertEquals(local.membership, merged.membership) + assertEquals(local.lastMessageAt, merged.lastMessageAt) + } + + @Test + fun `merging a partial update replaces the custom data and keeps the server fields`() { + val local = randomChannel( + extraData = mapOf( + "group" to "other", + "touched_at" to "2026-10-01T00:00:00.000Z", + "disabled" to false, + "muted" to true, + "truncated_at" to "2026-09-01T00:00:00.000Z", + ), + ) + + val merged = local.mergePartialUpdate(partial) + + val expected = mapOf( + "group" to "vip", + "disabled" to true, + "muted" to true, + "truncated_at" to "2026-09-01T00:00:00.000Z", + ) + assertEquals(expected, merged.extraData) + } + + @Test + fun `merging a partial update into channel data matches merging it into the channel`() { + val local = randomChannel(extraData = mapOf("group" to "other", "muted" to true)) + + val merged = local.toChannelData().mergePartialUpdate(partial) + + assertEquals(local.mergePartialUpdate(partial).toChannelData(), merged) + } +}