From cdb1d0a0a49a59f98ebb86aa2d0ee36d480735c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 13:27:56 +0100 Subject: [PATCH 01/16] Check the cutoff only for listed channels and move group badges for channels that are not loaded A stored copy's last activity can be stale, so a partial update on a channel that is not in the list only checks its group, and the list checks the cutoff after the watch. The group badge delta takes the old group and unread state from the stored channel when the channel has no active state, which is the usual case for this event. --- .../internal/GroupAwareChatEventHandler.kt | 21 ++++++------ .../internal/GroupedUnreadChannelsUpdater.kt | 17 ++++++---- .../internal/EventHandlerSequential.kt | 2 +- .../internal/GroupedPartialUpdateWatch.kt | 3 +- .../GroupAwareChatEventHandlerTest.kt | 18 +++++++++++ .../GroupedUnreadChannelsUpdaterTest.kt | 32 +++++++++++++++++-- .../internal/EventHandlerSequentialTest.kt | 30 +++++++++++++++++ 7 files changed, 102 insertions(+), 21 deletions(-) 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 c34a1ef3c487..475888607371 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 @@ -120,23 +120,22 @@ internal class GroupAwareChatEventHandler( * lacks data such as members and the last message time, so a channel that is not in the list yet is watched to * get its full data instead of being added from the event. * - * Without [cachedChannel], only the group is checked: the last activity is unknown, so the cutoff is left to the - * list, which checks the watched channel before showing it. The event is only sent to members, and a hidden - * channel is dropped after the watch. The query skips the watch when the channel sorts below its loaded pages. + * The cutoff is checked only for a channel in the list, whose last activity is up to date. For any other + * channel only the group is checked: without [cachedChannel] the last activity is unknown, and a stored copy can + * be stale, so the cutoff is left to the list, which checks the watched channel before showing it. The event is + * only sent to members, and a hidden channel is dropped after the watch. The query skips the watch when the + * channel sorts below its loaded pages. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, cachedChannel: Channel?, filter: FilterObject, ): EventHandlingResult { - val belongsHere = if (cachedChannel == null) { - groupMatches(event.channel, filter) - } else { - channelBelongsHere(cachedChannel.mergePartialUpdate(event.channel), filter) && - isCurrentUserMember(cachedChannel) && - cachedChannel.hidden != true - } + val channel = cachedChannel?.mergePartialUpdate(event.channel) ?: event.channel val isInList = channels.value?.containsKey(event.cid) == true + val matches = if (isInList) channelBelongsHere(channel, filter) else groupMatches(channel, filter) + val belongsHere = matches && + (cachedChannel == null || (isCurrentUserMember(cachedChannel) && cachedChannel.hidden != true)) return when { belongsHere && !isInList -> EventHandlingResult.WatchAndAdd(event.cid) !belongsHere && isInList -> EventHandlingResult.Remove(event.cid) @@ -148,7 +147,7 @@ internal class GroupAwareChatEventHandler( resolver.resolve(channel).contains(groupKey) && filter.matchesGroupedChannel(group = channel.group, queryTimestamp = channel.queryTimestamp, now = now()) - /** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown. */ + /** [channelBelongsHere] with the cutoff passed, for a channel whose last activity is unknown or may be stale. */ private fun groupMatches(channel: Channel, filter: FilterObject): Boolean { val checkedAt = now() return resolver.resolve(channel).contains(groupKey) && diff --git a/stream-chat-android-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 69ca04415f58..c64f4b70d820 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 @@ -96,7 +96,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. @@ -105,17 +105,21 @@ internal class GroupedUnreadChannelsUpdater( current: Map, batchId: Int, event: ChannelUpdatedByUserEvent, - ): Map = applyDelta(current, batchId, event.cid, event.channel) + ): Map = applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid)) /** * `notification.channel_partial_updated` delta. Same semantics as the [ChannelUpdatedEvent] overload. The - * event's channel carries the custom data, so it holds the new group. + * event's channel carries the custom data, so it holds the new group. The event goes to members who are not + * watching the channel, so it usually has no active state: its old group and unread state then come from + * [storedChannel], which is only read in that case. */ - fun calculateUpdatedCounts( + suspend fun calculateUpdatedCounts( current: Map, batchId: Int, event: NotificationChannelPartialUpdatedEvent, - ): Map = applyDelta(current, batchId, event.cid, event.channel) + storedChannel: suspend () -> Channel?, + ): Map = + applyDelta(current, batchId, event.cid, event.channel, cachedChannel(event.cid) ?: storedChannel()) /** * Records that channel [cid] has been removed within [batchId] — either deleted, or the @@ -147,10 +151,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-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 909a58969776..1ae994ceece1 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 @@ -455,7 +455,7 @@ internal class EventHandlerSequential( } (event as? NotificationChannelPartialUpdatedEvent)?.let { e -> groupedUnreadChannels = groupedUnreadChannelsUpdater - .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) + .calculateUpdatedCounts(groupedUnreadChannels, batchEvent.id, e) { repos.selectChannel(e.cid) } } (event as? HasOwnUser)?.let { modifyValuesFromUser(it.me) } (event as? HasUnreadThreadCounts)?.let { modifyUnreadThreadsCount(it) } diff --git a/stream-chat-android-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 index 66b6936d50c8..0b78373d4bf7 100644 --- 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 @@ -45,7 +45,8 @@ internal fun EventHandlingResult.skipWatchBelowLoadedChannels( /** * Whether [channel] sorts within the loaded channels: the group has no more pages, or the channel sorts at or above - * the last loaded channel. Without local data the channel has no last message time, so its position is a lower bound. + * the last loaded channel. Without local data the channel has no last message time, so its position is a lower bound: + * a channel with recent activity but no local copy can be skipped, and it then shows after the next refresh. */ private fun QueryChannelsState.isWithinLoadedChannels(channel: Channel): Boolean { if (endOfChannels.value) return true diff --git a/stream-chat-android-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 65375c78258d..8b5b913ec9ff 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 @@ -518,6 +518,24 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) } + @Test + fun `Given a stale copy past the cutoff and not in the list When a partial update moves it into the group Should watch and add`() { + // The copy's last activity can be stale, so the list checks the cutoff after the watch. + val currentUser = randomUser() + val channel = randomChannel( + createdAt = daysAgo(30), + lastMessageAt = daysAgo(20), + extraData = mapOf("group" to "other"), + ) + val cachedChannel = channel.copy(membership = randomMember(user = currentUser), hidden = false) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), currentUser = currentUser) + val event = partialUpdateOf(channel, custom = mapOf("group" to "vip")) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + @Test fun `Given a cached channel not in the list When a partial update moves it into the group Should watch and add`() { val currentUser = randomUser() diff --git a/stream-chat-android-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 3cb74a6eb3f0..83639ad3cb1c 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 @@ -36,6 +36,7 @@ import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomUser import io.getstream.chat.android.state.plugin.state.StateRegistry import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test @@ -203,16 +204,43 @@ internal class GroupedUnreadChannelsUpdaterTest { } @Test - fun `NotificationChannelPartialUpdatedEvent applies the same migration semantics`() { + fun `NotificationChannelPartialUpdatedEvent applies the same migration semantics`() = runTest { seedActiveChannel(oldGroup = "a", unreadMessages = 2) val current = mapOf("a" to 3, "b" to 1) val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) - val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { + error("The stored channel is not read for an active channel") + } assertEquals(mapOf("a" to 2, "b" to 2), next) } + @Test + fun `NotificationChannelPartialUpdatedEvent for an inactive channel migrates the count of its stored copy`() = + runTest { + val stored = channel(group = "a").copy( + read = listOf(randomChannelUserRead(user = User(id = CURRENT_USER_ID), unreadMessages = 2)), + ) + val current = mapOf("a" to 3, "b" to 1) + val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { stored } + + assertEquals(mapOf("a" to 2, "b" to 2), next) + } + + @Test + fun `NotificationChannelPartialUpdatedEvent for a channel with no local copy returns current map unchanged`() = + runTest { + val current = mapOf("a" to 3, "b" to 1) + val partialChannel = Channel(id = CHANNEL_ID, type = CHANNEL_TYPE, extraData = mapOf("group" to "b")) + + val next = updater.calculateUpdatedCounts(current, BATCH_ID, partialUpdatedEvent(partialChannel)) { null } + + assertSame(current, next) + } + // endregion // region Same-batch dedup diff --git a/stream-chat-android-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 5a765ddcff45..8a5e91630186 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 @@ -51,6 +51,7 @@ import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomChannelMute +import io.getstream.chat.android.randomChannelUserRead import io.getstream.chat.android.randomDraftMessage import io.getstream.chat.android.randomLocation import io.getstream.chat.android.randomMember @@ -307,6 +308,35 @@ internal class EventHandlerSequentialTest { 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 = 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 { From 47687e66f20fc81843699dcc0b55758631fa62a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 13:35:39 +0100 Subject: [PATCH 02/16] Keep disabled channels out of grouped lists The backend leaves disabled channels out of grouped queries. A channel update or partial update that disables a listed channel now removes it, re-enabling it adds it back, and events for a disabled channel no longer add it. --- .../internal/GroupAwareChatEventHandler.kt | 7 +- .../grouped/internal/GroupedChannelFilter.kt | 2 +- .../GroupAwareChatEventHandlerTest.kt | 81 ++++++++++++++++++- 3 files changed, 85 insertions(+), 5 deletions(-) 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 475888607371..7d2dbfefd72d 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 @@ -143,14 +143,17 @@ internal class GroupAwareChatEventHandler( } } + /** Whether [channel] belongs in this group. A disabled channel never does, since the backend leaves it out. */ private fun channelBelongsHere(channel: Channel, filter: FilterObject): Boolean = - resolver.resolve(channel).contains(groupKey) && + !channel.isDisabled && + 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) && + return !channel.isDisabled && + resolver.resolve(channel).contains(groupKey) && filter.matchesGroupedChannel(group = channel.group, queryTimestamp = checkedAt, now = checkedAt) } 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 5686f81a3a94..7fc6230fd567 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 @@ -61,7 +61,7 @@ internal fun Channel.isUnreadInGroupFor(userId: String): Boolean = hidden != true && !isDisabled && currentUserUnreadCount(userId) > 0 // The model has no field for it, so the server's `disabled` flag arrives in the extra data. -private val Channel.isDisabled: Boolean +internal val Channel.isDisabled: Boolean get() = extraData[DISABLED_FIELD_NAME] == true private val queryTimestampDescSort = QuerySortByField.descByName(QUERY_TIMESTAMP_FIELD_NAME) 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 8b5b913ec9ff..aeb2d42e0cb8 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 @@ -280,6 +280,55 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.Remove(channel.cid), result) } + @Test + fun `Given a channel in the list When ChannelUpdatedEvent disables it Should remove`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip", "disabled" to true)) + 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 = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a disabled channel not in the list When ChannelUpdatedEvent enables it Should add`() { + val currentUser = randomUser() + val channel = randomChannel(extraData = mapOf("group" to "vip", "disabled" to false)) + val cachedChannel = channel.copy( + membership = randomMember(user = currentUser), + hidden = false, + extraData = mapOf("group" to "vip", "disabled" to true), + ) + val handler = handlerFor( + groupKey = "vip", + cachedChannels = emptyMap(), + currentUser = currentUser, + ) + val event = randomChannelUpdatedEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Add(channel), result) + } + + @Test + fun `Given a disabled channel When NotificationAddedToChannelEvent arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "vip", "disabled" to true)) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationAddedToChannelEvent(cid = channel.cid, channel = channel) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, result) + } + @Test fun `Given no connected user When ChannelUpdatedEvent arrives Should skip`() { val channel = randomChannel(extraData = mapOf("group" to "vip")) @@ -518,6 +567,34 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) } + @Test + fun `Given a channel in the list When a partial update disables it 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 "vip"), disabled = true) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Remove(channel.cid), result) + } + + @Test + fun `Given a channel not in local state When a partial update moves it into the group disabled 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 "vip"), disabled = true) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = null) + + assertEquals(EventHandlingResult.Skip, 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. @@ -663,7 +740,7 @@ internal class GroupAwareChatEventHandlerTest { } /** The event as the backend sends it: only the channel's basic fields and its custom data. */ - private fun partialUpdateOf(channel: Channel, custom: Map) = + private fun partialUpdateOf(channel: Channel, custom: Map, disabled: Boolean = false) = randomNotificationChannelPartialUpdatedEvent( cid = channel.cid, channelType = channel.type, @@ -673,7 +750,7 @@ internal class GroupAwareChatEventHandlerTest { type = channel.type, createdAt = channel.createdAt, updatedAt = channel.updatedAt, - extraData = custom + ("disabled" to false), + extraData = custom + ("disabled" to disabled), ), ) From ea8e4f1a7a19d692ff578f1ba7e4eaafccd5221a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 13:48:36 +0100 Subject: [PATCH 03/16] Keep hidden channels hidden for messages that do not un-hide them on the server A new message shows a hidden channel again only when it is a regular message or a reply that is not shadowed, has no campaign id, and was not sent in a blocked channel or by a blocked user. The channel state, the stored channel and grouped lists follow the same rule. --- .../internal/GroupAwareChatEventHandler.kt | 32 ++++++-- .../GroupAwareChatEventHandlerFactory.kt | 3 + .../internal/EventHandlerSequential.kt | 10 ++- .../channel/internal/ChannelEventHandler.kt | 10 +-- .../channel/internal/ChannelStateLogic.kt | 13 ++++ .../plugin/logic/internal/LogicRegistry.kt | 1 + .../state/utils/internal/ChannelVisibility.kt | 42 +++++++++++ .../channel/controller/WhenHandleEvent.kt | 2 +- .../GroupAwareChatEventHandlerTest.kt | 61 +++++++++++++++ ...EventHandlerSequentialHiddenChannelTest.kt | 57 ++++++++++++++ .../internal/EventHandlerSequentialTest.kt | 40 ++++++++++ .../internal/ChannelEventHandlerTest.kt | 20 ++--- .../channel/internal/ChannelStateLogicTest.kt | 20 +++++ .../utils/internal/ChannelVisibilityTest.kt | 74 +++++++++++++++++++ 14 files changed, 351 insertions(+), 34 deletions(-) create mode 100644 stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandler.kt index 7d2dbfefd72d..435f6ba66bd9 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 @@ -34,6 +34,7 @@ import io.getstream.chat.android.state.event.handler.chat.DefaultChatEventHandle 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 io.getstream.chat.android.state.utils.internal.unhidesChannel import kotlinx.coroutines.flow.StateFlow import java.util.Date @@ -54,6 +55,7 @@ internal class GroupAwareChatEventHandler( private val resolver: ChannelGroupResolver, channels: StateFlow?>, clientState: ClientState, + private val blockedUserIds: () -> Collection, private val now: () -> Date = ::Date, ) : DefaultChatEventHandler(channels, clientState) { @@ -69,14 +71,16 @@ internal class GroupAwareChatEventHandler( is NotificationChannelPartialUpdatedEvent -> routePartialUpdate(event, cachedChannel, filter) + is NotificationMessageNewEvent -> + if ((cachedChannel ?: event.channel).staysHiddenAfter(event.message)) { + EventHandlingResult.Skip + } else { + watchAndAddIfBelongsHere(event.channel, filter) + } + is NotificationAddedToChannelEvent, - is NotificationMessageNewEvent, is ChannelVisibleEvent, - -> if (channelBelongsHere(event.channel, filter)) { - EventHandlingResult.WatchAndAdd(event.cid) - } else { - EventHandlingResult.Skip - } + -> watchAndAddIfBelongsHere(event.channel, filter) else -> super.handleChatEvent(event, filter, cachedChannel) } @@ -87,12 +91,24 @@ internal class GroupAwareChatEventHandler( filter: FilterObject, cachedChannel: Channel?, ): EventHandlingResult { - val defaultResult = super.handleCidEvent(event, filter, cachedChannel) - // The cached channel can predate the new message, which may bring it back within a cutoff. + // The cached channel can predate the new message, which may bring it back within a cutoff or un-hide it. val newMessage = (event as? NewMessageEvent)?.message + if (newMessage != null && cachedChannel?.staysHiddenAfter(newMessage) == true) return EventHandlingResult.Skip + val defaultResult = super.handleCidEvent(event, filter, cachedChannel) return filterResultByGroup(defaultResult, cachedChannel, filter, newMessage) } + private fun watchAndAddIfBelongsHere(channel: Channel, filter: FilterObject): EventHandlingResult = + if (channelBelongsHere(channel, filter)) { + EventHandlingResult.WatchAndAdd(channel.cid) + } else { + EventHandlingResult.Skip + } + + /** Whether this channel is hidden and the new [message] keeps it hidden on the server. */ + private fun Channel.staysHiddenAfter(message: Message): Boolean = + hidden == true && !message.unhidesChannel(extraData, blockedUserIds()) + /** * The membership guard prevents a `ChannelUpdatedEvent` from (re-)adding a channel the user * has already left — relies on [cachedChannel] (in-memory state after `member.removed` is diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt index b594fc593711..000a365532f0 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/grouped/internal/GroupAwareChatEventHandlerFactory.kt @@ -22,6 +22,7 @@ import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel import io.getstream.chat.android.state.event.handler.chat.ChatEventHandler import io.getstream.chat.android.state.event.handler.chat.factory.ChatEventHandlerFactory +import io.getstream.chat.android.state.extensions.globalState import kotlinx.coroutines.flow.StateFlow /** @@ -34,6 +35,7 @@ internal class GroupAwareChatEventHandlerFactory( private val groupKey: String, private val resolver: ChannelGroupResolver = DefaultChannelGroupResolver(), private val clientState: ClientState = ChatClient.instance().clientState, + private val blockedUserIds: () -> Collection = { ChatClient.instance().globalState.blockedUserIds.value }, ) : ChatEventHandlerFactory(clientState) { override fun chatEventHandler(channels: StateFlow?>): ChatEventHandler = @@ -42,6 +44,7 @@ internal class GroupAwareChatEventHandlerFactory( resolver = resolver, channels = channels, clientState = clientState, + blockedUserIds = blockedUserIds, ) } 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 1ae994ceece1..b42287ac6852 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 @@ -102,6 +102,7 @@ import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.utils.mergePartially import io.getstream.chat.android.client.utils.observable.Disposable import io.getstream.chat.android.core.internal.lazy.parameterizedLazy +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelCapabilities import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message @@ -125,6 +126,7 @@ 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.chat.android.state.utils.internal.unhidesChannel import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineExceptionHandler @@ -649,7 +651,7 @@ internal class EventHandlerSequential( skipLastMsgUpdateForSystemMsgs = channel.config.skipLastMsgUpdateForSystemMsgs, ) val updatedChannel = channel.copy( - hidden = channel.hidden.takeIf { enrichedMessage.shadowed } ?: false, + hidden = channel.hiddenAfter(enrichedMessage), messages = channel.messages + listOf(enrichedMessage), messageCount = event.channelMessageCount ?: channel.messageCount, lastMessageAt = newLastMessageAt, @@ -690,7 +692,7 @@ internal class EventHandlerSequential( batch.addMessageData(event.createdAt, event.cid, event.message) val channel = batch.getCurrentChannel(event.cid) ?.mergeChannelFromEvent(event.channel) ?: event.channel - batch.addChannel(channel.copy(hidden = false)) + batch.addChannel(channel.copy(hidden = channel.hiddenAfter(event.message))) } is NotificationAddedToChannelEvent -> { val channel = batch.getCurrentChannel(event.cid) @@ -958,6 +960,10 @@ internal class EventHandlerSequential( logger.v { "[updateOfflineStorage] completed batchId: ${batchEvent.id}" } } + /** The hidden state of this channel after the new [message]. */ + private fun Channel.hiddenAfter(message: Message): Boolean? = + if (message.unhidesChannel(extraData, mutableGlobalState.blockedUserIds.value)) false else hidden + private fun List.extractMessageIds() = mapNotNull { event -> (event as? HasMessage)?.message?.id } 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 d810fca392e7..1c3b4cade694 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 @@ -129,10 +129,7 @@ internal class ChannelEventHandler( upsertMessage(event.message, preserveCreatedLocallyAt) // Update channel read state stateLogic.updateCurrentUserRead(event.createdAt, event.message) - // Update hidden state if the message is not shadowed - if (!event.message.shadowed) { - stateLogic.setHidden(false) - } + stateLogic.unhideFor(event.message) // Update message count event.channelMessageCount?.let(stateLogic::updateMessageCount) // Update lastMessageAt @@ -145,10 +142,7 @@ internal class ChannelEventHandler( } // Update channel read state stateLogic.updateCurrentUserRead(event.createdAt, event.message) - // Update hidden state if the message is not shadowed - if (!event.message.shadowed) { - stateLogic.setHidden(false) - } + stateLogic.unhideFor(event.message) // Update lastMessageAt stateLogic.updateLastMessageAt(event.message) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt index b17b39961c78..b48c92530381 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt @@ -47,6 +47,7 @@ import io.getstream.chat.android.state.message.attachments.internal.AttachmentUr import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState 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.unhidesChannel import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import io.getstream.result.Error @@ -588,6 +589,18 @@ internal class ChannelStateLogic( mutableState.setHidden(hidden) } + /** + * Shows the channel again when the new [message] makes the server show it. + * + * @param message The new message of the channel. + */ + fun unhideFor(message: Message) { + val extraData = mutableState.channelData.value.extraData + if (message.unhidesChannel(extraData, globalMutableState.blockedUserIds.value)) { + setHidden(false) + } + } + /** * Sets a replied message. * diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt index d9dffcb5f07c..8c677566bddb 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/internal/LogicRegistry.kt @@ -122,6 +122,7 @@ internal class LogicRegistry internal constructor( mutableState.chatEventHandlerFactory = GroupAwareChatEventHandlerFactory( groupKey = identifier.groupKey, clientState = clientState, + blockedUserIds = { mutableGlobalState.blockedUserIds.value }, ) } val queryChannelsStateLogic = QueryChannelsStateLogic( diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt new file mode 100644 index 000000000000..0a19b622b415 --- /dev/null +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt @@ -0,0 +1,42 @@ +/* + * 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.Message +import io.getstream.chat.android.models.MessageType + +private const val BLOCKED_FIELD_NAME = "blocked" +private const val CREATED_BY_CAMPAIGN_ID_FIELD_NAME = "created_by_campaign_id" + +/** + * Whether this new message makes the server show a hidden channel again for the current user. Only a regular message + * or a reply does, and not when it is shadowed, sent by a campaign, sent in a blocked channel or sent by a user the + * current user blocked. + * + * @param channelExtraData The channel's extra data, which holds the server's `blocked` flag. + * @param blockedUserIds The ids of the users the current user blocked. + */ +internal fun Message.unhidesChannel(channelExtraData: Map, blockedUserIds: Collection): Boolean = + when (type) { + MessageType.REGULAR, MessageType.REPLY -> + !shadowed && + extraData[CREATED_BY_CAMPAIGN_ID_FIELD_NAME] == null && + channelExtraData[BLOCKED_FIELD_NAME] != true && + user.id !in blockedUserIds + + else -> false + } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/channel/controller/WhenHandleEvent.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/channel/controller/WhenHandleEvent.kt index fd4c0ca60cbd..c2320c39ae40 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/channel/controller/WhenHandleEvent.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/channel/controller/WhenHandleEvent.kt @@ -133,7 +133,7 @@ internal class WhenHandleEvent : SynchronizedCoroutineTest { verify(channelStateLogic).upsertMessage(newMessage) verify(channelStateLogic).updateCurrentUserRead(userStartWatchingEvent.createdAt, userStartWatchingEvent.message) - verify(channelStateLogic).setHidden(false) + verify(channelStateLogic).unhideFor(userStartWatchingEvent.message) } // Message update 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 aeb2d42e0cb8..ee7a841432ab 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 @@ -43,6 +43,7 @@ import org.mockito.kotlin.whenever import java.util.Date import java.util.concurrent.TimeUnit +@Suppress("LargeClass") internal class GroupAwareChatEventHandlerTest { private val defaultResolver = DefaultChannelGroupResolver() @@ -486,6 +487,64 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.Add(cachedChannel.copy(lastMessageAt = now)), result) } + @Test + fun `Given a hidden cached channel When a campaign message arrives Should skip`() { + val cachedChannel = randomChannel(extraData = mapOf("group" to "vip"), hidden = true) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val message = channelMessage(createdAt = now).copy(extraData = mapOf("created_by_campaign_id" to "campaign")) + val event = randomNewMessageEvent(cid = cachedChannel.cid, createdAt = now, message = message) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a hidden cached channel When a regular message arrives Should add it back`() { + val cachedChannel = randomChannel(extraData = mapOf("group" to "vip"), hidden = true, lastMessageAt = now) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNewMessageEvent( + cid = cachedChannel.cid, + createdAt = now, + message = channelMessage(createdAt = now), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Add(cachedChannel), result) + } + + @Test + fun `Given a hidden cached channel When NotificationMessageNewEvent from a blocked user arrives Should skip`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val sender = randomUser() + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap(), blockedUserIds = listOf(sender.id)) + val event = randomNotificationMessageNewEvent( + cid = channel.cid, + channel = channel, + message = channelMessage(createdAt = now).copy(user = sender), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel.copy(hidden = true)) + + assertEquals(EventHandlingResult.Skip, result) + } + + @Test + fun `Given a hidden cached channel When NotificationMessageNewEvent with a regular message arrives Should watch and add`() { + val channel = randomChannel(extraData = mapOf("group" to "vip")) + val handler = handlerFor(groupKey = "vip", cachedChannels = emptyMap()) + val event = randomNotificationMessageNewEvent( + cid = channel.cid, + channel = channel, + message = channelMessage(createdAt = now), + ) + + val result = handler.handleChatEvent(event, Filters.neutral(), cachedChannel = channel.copy(hidden = true)) + + assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) + } + @Test fun `Given a cached channel past the cutoff When a thread reply not shown in the channel arrives Should skip`() { val cachedChannel = randomChannel( @@ -770,6 +829,7 @@ internal class GroupAwareChatEventHandlerTest { cachedChannels: Map, resolver: ChannelGroupResolver = defaultResolver, currentUser: User? = null, + blockedUserIds: List = emptyList(), ): GroupAwareChatEventHandler { val clientState = mock { whenever(it.user) doReturn MutableStateFlow(currentUser) @@ -779,6 +839,7 @@ internal class GroupAwareChatEventHandlerTest { resolver = resolver, channels = MutableStateFlow(cachedChannels), clientState = clientState, + blockedUserIds = { blockedUserIds }, now = { now }, ) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt index 66920c316818..d699bc9534bd 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/EventHandlerSequentialHiddenChannelTest.kt @@ -29,6 +29,7 @@ import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Location import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomChannel @@ -181,6 +182,55 @@ internal class EventHandlerSequentialHiddenChannelTest { groupedState.rawChannels.orEmpty().keys `should contain` CID } + @Test + fun `a campaign message does not bring a hidden channel back to a grouped query`() = runTest { + val fixture = Fixture() + fixture.withActiveChannel(CHANNEL_TYPE, CHANNEL_ID, randomMember(user = fixture.currentUser)) + val groupedState = fixture.withLoadedGroupedQuery(groupKey = "all") + val eventHandler = fixture.get() + + eventHandler.handleEvents(hiddenEvent(fixture.currentUser)) + eventHandler.handleEvents( + newMessageEvent( + randomMessage( + type = "regular", + shadowed = false, + extraData = mapOf("created_by_campaign_id" to "campaign-id"), + ), + ), + ) + + groupedState.rawChannels.orEmpty().keys `should not contain` CID + } + + @Test + fun `a regular message brings a hidden channel back to a grouped query`() = runTest { + val fixture = Fixture() + fixture.withActiveChannel(CHANNEL_TYPE, CHANNEL_ID, randomMember(user = fixture.currentUser)) + val groupedState = fixture.withLoadedGroupedQuery(groupKey = "all") + val eventHandler = fixture.get() + + eventHandler.handleEvents(hiddenEvent(fixture.currentUser)) + eventHandler.handleEvents(newMessageEvent(randomMessage(type = "regular", shadowed = false))) + + groupedState.rawChannels.orEmpty().keys `should contain` CID + } + + private fun hiddenEvent(user: User) = randomChannelHiddenEvent( + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + user = user, + clearHistory = false, + ) + + private fun newMessageEvent(message: Message) = randomNewMessageEvent( + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + message = message, + ) + private fun updatedChannel(): Channel = randomChannel( id = CHANNEL_ID, type = CHANNEL_TYPE, @@ -245,6 +295,13 @@ internal class EventHandlerSequentialHiddenChannelTest { return stateRegistry.queryChannels(identifier).toMutableState() } + /** Registers a grouped query with a loaded page that holds another channel. */ + suspend fun withLoadedGroupedQuery(groupKey: String): QueryChannelsMutableState { + val identifier = QueryChannelsIdentifier.Grouped(groupKey) + logicRegistry.queryChannels(identifier).addChannel(randomChannel(hidden = false)) + return stateRegistry.queryChannels(identifier).toMutableState() + } + fun get() = EventHandlerSequential( currentUserId = currentUser.id, subscribeForEvents = { EventHandlerSequential.EMPTY_DISPOSABLE }, 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 8a5e91630186..c8d4da28c7a7 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 @@ -44,6 +44,7 @@ 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 +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Mute import io.getstream.chat.android.models.User import io.getstream.chat.android.positiveRandomInt @@ -72,6 +73,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.amshove.kluent.`should be equal to` @@ -365,6 +367,44 @@ internal class EventHandlerSequentialTest { storedResult.extraData `should be equal to` mapOf("group" to "b") } + @Test + fun `When handling NewMessageEvent with a campaign message, The stored hidden channel should stay hidden`() = + runTest { + val message = randomMessage( + type = "regular", + shadowed = false, + extraData = mapOf("created_by_campaign_id" to "campaign-id"), + ) + + storedHiddenChannelAfter(message).hidden `should be equal to` true + } + + @Test + fun `When handling NewMessageEvent with a regular message, The stored hidden channel should be shown`() = runTest { + val message = randomMessage(type = "regular", shadowed = false, extraData = emptyMap()) + + storedHiddenChannelAfter(message).hidden `should be equal to` false + } + + /** Handles a new [message] in a stored hidden channel and returns the channel as it is stored again. */ + private suspend fun TestScope.storedHiddenChannelAfter(message: Message): Channel { + val storedChannel = randomChannel(hidden = true, extraData = emptyMap()) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn listOf(storedChannel) + whenever(repos.selectThreads(any())) doReturn emptyList() + whenever(repos.selectChannel(storedChannel.cid)) doReturn storedChannel + val handler = Fixture() + .withRepositoryFacade(repos) + .get(this) + + handler.handleEvents(randomNewMessageEvent(cid = storedChannel.cid, message = message)) + + val inserted = argumentCaptor>() + verify(repos).insertChannels(inserted.capture()) + return inserted.firstValue.single() + } + @Test fun `When handling NotificationChannelPartialUpdatedEvent, A channel that is not stored should not be inserted`() = runTest { 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 d8dfa212bbee..75e25158b2f2 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 @@ -158,23 +158,13 @@ internal class ChannelEventHandlerTest { } @Test - fun `When NewMessageEvent with non-shadowed message is handled, Then channel is unhidden`() { - val message = randomMessage(shadowed = false) - val event = randomNewMessageEvent(cid = cid, message = message) - - handler.handle(event) - - verify(stateLogic).setHidden(false) - } - - @Test - fun `When NewMessageEvent with shadowed message is handled, Then channel is not unhidden`() { - val message = randomMessage(shadowed = true) + fun `When NewMessageEvent is handled, Then the message decides whether the channel is shown again`() { + val message = randomMessage() val event = randomNewMessageEvent(cid = cid, message = message) handler.handle(event) - verify(stateLogic, never()).setHidden(false) + verify(stateLogic).unhideFor(message) } @Test @@ -316,7 +306,7 @@ internal class ChannelEventHandlerTest { verify(stateLogic).upsertMessage(message) verify(stateLogic).updateCurrentUserRead(event.createdAt, message) - verify(stateLogic).setHidden(false) + verify(stateLogic).unhideFor(message) } @Test @@ -329,7 +319,7 @@ internal class ChannelEventHandlerTest { verify(stateLogic, never()).upsertMessage(message) verify(stateLogic).updateCurrentUserRead(event.createdAt, message) - verify(stateLogic).setHidden(false) + verify(stateLogic).unhideFor(message) } // NotificationThreadMessageNewEvent tests diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt index 3b8503edff2e..8193673395af 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt @@ -461,6 +461,26 @@ internal class ChannelStateLogicTest { channelStateLogic.getPoll(poll.id) `should be equal to` null } + @Test + fun `Given a regular message, When calling unhideFor, Then the channel is shown`() { + val message = randomMessage(type = MessageType.REGULAR, shadowed = false, extraData = emptyMap()) + + channelStateLogic.unhideFor(message) + + verify(mutableState).setHidden(false) + } + + @Test + fun `Given a message from a blocked user, When calling unhideFor, Then the channel stays hidden`() { + val sender = randomUser() + spyMutableGlobalState.setBlockedUserIds(listOf(sender.id)) + val message = randomMessage(type = MessageType.REGULAR, shadowed = false, extraData = emptyMap(), user = sender) + + channelStateLogic.unhideFor(message) + + verify(mutableState, never()).setHidden(any()) + } + @Test fun `Given user has no messages, When calling deleteMessagesFromUser, Then no action is taken`() { // given diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt new file mode 100644 index 000000000000..7ef9ea5a3da4 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.utils.internal + +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class ChannelVisibilityTest { + + @Test + fun `A regular message un-hides the channel`() { + assertTrue(message(type = MessageType.REGULAR).unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A reply un-hides the channel`() { + assertTrue(message(type = MessageType.REPLY).unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A system message keeps the channel hidden`() { + assertFalse(message(type = MessageType.SYSTEM).unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A shadowed message keeps the channel hidden`() { + assertFalse(message(shadowed = true).unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A campaign message keeps the channel hidden`() { + val message = message(extraData = mapOf("created_by_campaign_id" to randomString())) + + assertFalse(message.unhidesChannel(emptyMap(), emptyList())) + } + + @Test + fun `A message in a blocked channel keeps the channel hidden`() { + assertFalse(message().unhidesChannel(mapOf("blocked" to true), emptyList())) + } + + @Test + fun `A message from a blocked user keeps the channel hidden`() { + val message = message() + + assertFalse(message.unhidesChannel(emptyMap(), listOf(message.user.id))) + } + + private fun message( + type: String = MessageType.REGULAR, + shadowed: Boolean = false, + extraData: Map = emptyMap(), + ): Message = randomMessage(type = type, shadowed = shadowed, extraData = extraData, user = randomUser()) +} From bb1a7fb914c7648fe764d5877d0c63cba746201c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 13:51:55 +0100 Subject: [PATCH 04/16] Watch partially updated channels without a local copy only for grouped lists queried with watch A grouped list queried without watch now skips a partial update on a channel it has no local copy of. The channel shows when its page loads. --- .../internal/GroupAwareChatEventHandler.kt | 2 +- .../internal/GroupedPartialUpdateWatch.kt | 18 ++++++++ .../internal/QueryChannelsLogic.kt | 2 + .../QueryChannelsLogicGroupedWatchTest.kt | 41 +++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) 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 435f6ba66bd9..05ec012fb025 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 @@ -140,7 +140,7 @@ internal class GroupAwareChatEventHandler( * 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. + * channel sorts below its loaded pages, or when it has no local copy and the list was not queried with `watch`. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, 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 index 0b78373d4bf7..5dc5ae80d823 100644 --- 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 @@ -43,6 +43,24 @@ internal fun EventHandlingResult.skipWatchBelowLoadedChannels( return if (state.isWithinLoadedChannels(channel)) this else EventHandlingResult.Skip } +/** + * Turns a [EventHandlingResult.WatchAndAdd] for a partial channel update into [EventHandlingResult.Skip] when the + * channel has no local copy and the grouped list was not queried with `watch`. Such a channel is shown when its page + * loads. 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 watch Whether the grouped list was queried with `watch`. + */ +internal fun EventHandlingResult.skipWatchWithoutLocalCopy( + event: ChatEvent, + cachedChannel: Channel?, + watch: Boolean, +): EventHandlingResult { + if (this !is EventHandlingResult.WatchAndAdd || event !is NotificationChannelPartialUpdatedEvent) return this + return if (cachedChannel == null && !watch) EventHandlingResult.Skip else this +} + /** * 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: 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 5a173994e389..507cdab9f843 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 @@ -687,6 +687,8 @@ internal class QueryChannelsLogic( when (identifier) { is QueryChannelsIdentifier.Grouped -> result.skipWatchBelowLoadedChannels(event, channel, queryChannelsStateLogic.getState()) + // Without a known request, the list is treated as watching, as the reconnect sync does. + .skipWatchWithoutLocalCopy(event, channel, watch = groupedQueryConfig()?.watch ?: true) else -> result } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt index 81b1bc6d0afe..5421c694eb69 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -27,6 +27,7 @@ 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.GroupedQueryConfig import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState import io.getstream.chat.android.test.asCall import kotlinx.coroutines.flow.MutableStateFlow @@ -124,6 +125,46 @@ internal class QueryChannelsLogicGroupedWatchTest { assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) } + @Test + fun `a partial update skips a channel without a local copy when the list was queried without watch`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + givenGroupedQueryWatch(watch = false) + val event = partialUpdate(createdAt = daysAgo(1)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.Skip), results) + } + + @Test + fun `a partial update watches a stored channel when the list was queried without watch`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + givenGroupedQueryWatch(watch = false) + val event = partialUpdate(createdAt = daysAgo(1)) + val stored = randomChannel(id = event.channelId, type = event.channelType, createdAt = 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 `a partial update watches a channel without a local copy when the list was queried with watch`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + givenGroupedQueryWatch(watch = true) + val event = partialUpdate(createdAt = daysAgo(1)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) + } + + private fun givenGroupedQueryWatch(watch: Boolean) { + whenever(queryChannelsStateLogic.getGroupedQueryConfig()) doReturn + GroupedQueryConfig(limit = null, pageSize = null, watch = watch, presence = false) + } + private suspend fun givenLoadedChannels(lastLoadedAt: Date, endOfChannels: Boolean) { val loaded = listOf( randomChannel(createdAt = lastLoadedAt, lastMessageAt = null), From e87d01ce334040bab6ce9995f61fbb1d1a99da6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 14:02:37 +0100 Subject: [PATCH 05/16] Reconnect once before scrolling in the grouped reconnect e2e test After a fresh login no sync date is stored, so the test's only reconnect reset the pages. One reconnect after opening the tab stores the sync date first. --- .../compose/tests/GroupedChannelListTests.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt index f9b6a0eaa42d..5c027ace0ca1 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt @@ -54,11 +54,19 @@ class GroupedChannelListTests : StreamTestCase() { step("GIVEN backend has two pages of new channels") { seedNewChannels(channelsCount = 20) } + step("AND user opens the new tab") { + userRobot.login().waitForChannelListToLoad().openGroupTab("new") + } + step("AND user reconnects once, so the app has a last sync date") { + // After a fresh login, the first sync date is stored by the first reconnection. Pages are kept only after + // a sync that starts from a known date. + device.disableInternetConnection() + userRobot.sleep(2.seconds) + device.enableInternetConnection() + userRobot.sleep(3.seconds) + } step("AND user scrolls to the end of the second page of the new tab") { userRobot - .login() - .waitForChannelListToLoad() - .openGroupTab("new") // The first scroll loads the second page, the second one reaches its end. .scrollChannelListDown() .scrollChannelListDown() From 582fff457b11565f90f1371d61bfaa972615131a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 14:14:56 +0100 Subject: [PATCH 06/16] Open the grouped tab online in the reconnection e2e test A tab asks for its next page when it is shown, and a request rejected while offline is not sent again. Going offline right after opening the tab could leave the list at its first page. --- .../android/compose/tests/GroupedChannelListTests.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt index 5c027ace0ca1..5216ff83707f 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt @@ -54,12 +54,10 @@ class GroupedChannelListTests : StreamTestCase() { step("GIVEN backend has two pages of new channels") { seedNewChannels(channelsCount = 20) } - step("AND user opens the new tab") { - userRobot.login().waitForChannelListToLoad().openGroupTab("new") - } - step("AND user reconnects once, so the app has a last sync date") { + step("AND user logs in and reconnects once, so the app has a last sync date") { // After a fresh login, the first sync date is stored by the first reconnection. Pages are kept only after // a sync that starts from a known date. + userRobot.login().waitForChannelListToLoad() device.disableInternetConnection() userRobot.sleep(2.seconds) device.enableInternetConnection() @@ -67,6 +65,9 @@ class GroupedChannelListTests : StreamTestCase() { } step("AND user scrolls to the end of the second page of the new tab") { userRobot + // Opened online: a tab asks for its next page when it is shown, and a request rejected while offline + // is not sent again. + .openGroupTab("new") // The first scroll loads the second page, the second one reaches its end. .scrollChannelListDown() .scrollChannelListDown() From acb9635469de8263270e2320a8ab89ee49c741e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 14:41:13 +0100 Subject: [PATCH 07/16] Place partially updated grouped channels by the last message time of the event The partial channel now carries last_message_at, and a partial update keeps the later of the local and the event value. A stored channel that is not in the list is checked against the cutoff when the event carries its last message time; without it, only the group is checked, as before. --- .../client/api2/mapping/DomainMapping.kt | 1 + .../client/api2/model/dto/ChannelDtos.kt | 1 + .../android/client/EventChatJsonProvider.kt | 1 + .../client/api2/mapping/DomainMappingTest.kt | 22 ++++++++++ .../android/client/parser/EventArguments.kt | 1 + .../internal/GroupAwareChatEventHandler.kt | 18 ++++---- .../utils/internal/ChannelPartialUpdate.kt | 4 +- .../GroupAwareChatEventHandlerTest.kt | 42 ++++++++++++++++++- .../QueryChannelsLogicGroupedWatchTest.kt | 19 ++++++++- .../internal/ChannelPartialUpdateTest.kt | 19 +++++++++ 10 files changed, 116 insertions(+), 12 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 7e431f86014b..9de5e5142c43 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 @@ -208,6 +208,7 @@ internal class DomainMapping( frozen = frozen, createdAt = created_at, updatedAt = updated_at, + lastMessageAt = last_message_at, team = team, extraData = (extraData + (CHANNEL_DISABLED_FIELD to disabled)).toMutableMap(), ).let(channelTransformer::transform) 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 613543a16958..1aba7fe65840 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 @@ -62,6 +62,7 @@ internal data class DownstreamChannelPartialUpdateDto( val image: String?, val created_at: Date?, val updated_at: Date?, + val last_message_at: Date? = null, val frozen: Boolean, val disabled: Boolean, val team: String = "", 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 a87d9de89072..a7322e63296a 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 @@ -251,6 +251,7 @@ internal fun createNotificationChannelPartialUpdatedEventStringJson() = "cid": "channelType:channelId", "created_at": "2020-06-29T06:14:28.000Z", "updated_at": "2020-06-29T06:14:28.000Z", + "last_message_at": "2020-06-29T06:14:28.000Z", "frozen": true, "disabled": false, "team": "team1", 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 42a4b0f413c4..7f22f9f5d7d6 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 @@ -306,6 +306,28 @@ internal class DomainMappingTest { assertEquals("", result.image) } + @Test + fun `Partially updated channel keeps the last message time of the event`() { + val lastMessageAt = randomDate() + val dto = DownstreamChannelPartialUpdateDto( + cid = "messaging:123", + id = "123", + type = "messaging", + name = null, + image = null, + created_at = null, + updated_at = null, + last_message_at = lastMessageAt, + frozen = false, + disabled = false, + extraData = emptyMap(), + ) + + val result = with(Fixture().get()) { dto.toDomain() } + + assertEquals(lastMessageAt, result.lastMessageAt) + } + @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/parser/EventArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser/EventArguments.kt index c13051dcd9d1..a9a3417f6e99 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 @@ -561,6 +561,7 @@ internal object EventArguments { frozen = true, createdAt = date, updatedAt = date, + lastMessageAt = date, team = "team1", extraData = mutableMapOf("group" to "current", "disabled" to false), ), 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 05ec012fb025..fdda9e6f13e7 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 @@ -133,14 +133,14 @@ 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. + * lacks data such as members, 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, or when it has no local copy and the list was not queried with `watch`. + * The cutoff is checked when the channel's last activity is known: the channel is in the list, or it has a local + * copy and the event carries its last message time. For any other channel only the group is checked, and 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, or when it has no local copy and the list was not queried with `watch`. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, @@ -149,7 +149,9 @@ internal class GroupAwareChatEventHandler( ): 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) + // A stored copy can miss recent messages, so its last activity is known only with the event's value. + val isLastActivityKnown = isInList || (cachedChannel != null && event.channel.lastMessageAt != null) + val matches = if (isLastActivityKnown) channelBelongsHere(channel, filter) else groupMatches(channel, filter) val belongsHere = matches && (cachedChannel == null || (isCurrentUserMember(cachedChannel) && cachedChannel.hidden != true)) return when { 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 index 8c1269e16fbd..2c1d316a15da 100644 --- 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 @@ -17,6 +17,7 @@ package io.getstream.chat.android.state.utils.internal import io.getstream.chat.android.client.events.NotificationChannelPartialUpdatedEvent +import io.getstream.chat.android.core.utils.date.max import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelData @@ -40,7 +41,7 @@ private val serverFieldsInExtraData = setOf( /** * Applies the fields carried by [NotificationChannelPartialUpdatedEvent.channel] to this channel and keeps all - * the others, such as members, reads and [Channel.lastMessageAt]. + * the others, such as members and reads. [Channel.lastMessageAt] takes the later of the local and the event value. */ internal fun Channel.mergePartialUpdate(partial: Channel): Channel = copy( name = partial.name, @@ -49,6 +50,7 @@ internal fun Channel.mergePartialUpdate(partial: Channel): Channel = copy( team = partial.team, createdAt = partial.createdAt, updatedAt = partial.updatedAt, + lastMessageAt = max(lastMessageAt, partial.lastMessageAt), extraData = mergePartialExtraData(current = extraData, partial = partial.extraData), ) 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 ee7a841432ab..d13d9919bdba 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 @@ -672,6 +672,40 @@ internal class GroupAwareChatEventHandlerTest { assertEquals(EventHandlingResult.WatchAndAdd(channel.cid), result) } + @Test + fun `Given a stale copy not in the list When a partial update carries a recent last message time Should watch and add`() { + 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"), lastMessageAt = daysAgo(1)) + + 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 carries a last message time past the cutoff Should skip`() { + 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"), lastMessageAt = daysAgo(20)) + + val result = handler.handleChatEvent(event, cutoff14Days, cachedChannel = cachedChannel) + + assertEquals(EventHandlingResult.Skip, 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() @@ -799,7 +833,12 @@ internal class GroupAwareChatEventHandlerTest { } /** The event as the backend sends it: only the channel's basic fields and its custom data. */ - private fun partialUpdateOf(channel: Channel, custom: Map, disabled: Boolean = false) = + private fun partialUpdateOf( + channel: Channel, + custom: Map, + disabled: Boolean = false, + lastMessageAt: Date? = null, + ) = randomNotificationChannelPartialUpdatedEvent( cid = channel.cid, channelType = channel.type, @@ -809,6 +848,7 @@ internal class GroupAwareChatEventHandlerTest { type = channel.type, createdAt = channel.createdAt, updatedAt = channel.updatedAt, + lastMessageAt = lastMessageAt, extraData = custom + ("disabled" to disabled), ), ) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt index 5421c694eb69..c6f951e1f1b5 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -149,6 +149,16 @@ internal class QueryChannelsLogicGroupedWatchTest { assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) } + @Test + fun `a partial update places a channel without a local copy by the last message time of the event`() = runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + val event = partialUpdate(createdAt = daysAgo(30), lastMessageAt = daysAgo(1)) + + val results = logic.parseChatEventResults(listOf(event)) + + assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) + } + @Test fun `a partial update watches a channel without a local copy when the list was queried with watch`() = runTest { givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) @@ -177,8 +187,13 @@ internal class QueryChannelsLogicGroupedWatchTest { } /** 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) + private fun partialUpdate(createdAt: Date, lastMessageAt: Date? = null): NotificationChannelPartialUpdatedEvent { + val slim = Channel( + id = randomString(), + type = "messaging", + createdAt = createdAt, + lastMessageAt = lastMessageAt, + ) val event = randomNotificationChannelPartialUpdatedEvent( cid = slim.cid, channelType = slim.type, 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 index 23daea0dab89..070fbf7a3703 100644 --- 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 @@ -24,6 +24,7 @@ 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 +import java.util.Date internal class ChannelPartialUpdateTest { @@ -68,6 +69,24 @@ internal class ChannelPartialUpdateTest { assertEquals(local.lastMessageAt, merged.lastMessageAt) } + @Test + fun `merging a partial update takes a later last message time from the event`() { + val local = randomChannel(lastMessageAt = Date(1_000)) + + val merged = local.mergePartialUpdate(partial.copy(lastMessageAt = Date(2_000))) + + assertEquals(Date(2_000), merged.lastMessageAt) + } + + @Test + fun `merging a partial update keeps a later local last message time`() { + val local = randomChannel(lastMessageAt = Date(2_000)) + + val merged = local.mergePartialUpdate(partial.copy(lastMessageAt = Date(1_000))) + + assertEquals(Date(2_000), merged.lastMessageAt) + } + @Test fun `merging a partial update replaces the custom data and keeps the server fields`() { val local = randomChannel( From c72041ca22480195a71e77ed7da3f2752d997e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 14:41:13 +0100 Subject: [PATCH 08/16] Add stored channels without watching them for grouped lists queried without watch A partial update on a stored channel that moves into a grouped list queried without watch now adds the stored copy with the update applied, instead of watching the channel. --- .../internal/GroupAwareChatEventHandler.kt | 2 +- .../internal/GroupedPartialUpdateWatch.kt | 11 +++++----- .../internal/QueryChannelsLogic.kt | 2 +- .../QueryChannelsLogicGroupedWatchTest.kt | 20 ++++++++++--------- 4 files changed, 19 insertions(+), 16 deletions(-) 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 fdda9e6f13e7..1617f07697d6 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 @@ -140,7 +140,7 @@ internal class GroupAwareChatEventHandler( * copy and the event carries its last message time. For any other channel only the group is checked, and 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, or when it has no local copy and the list was not queried with `watch`. + * its loaded pages, and does not watch it when the list was not queried with `watch`. */ private fun routePartialUpdate( event: NotificationChannelPartialUpdatedEvent, 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 index 5dc5ae80d823..f4818aef5c53 100644 --- 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 @@ -44,21 +44,22 @@ internal fun EventHandlingResult.skipWatchBelowLoadedChannels( } /** - * Turns a [EventHandlingResult.WatchAndAdd] for a partial channel update into [EventHandlingResult.Skip] when the - * channel has no local copy and the grouped list was not queried with `watch`. Such a channel is shown when its page - * loads. Other results and events are returned unchanged. + * Drops the watch from a [EventHandlingResult.WatchAndAdd] for a partial channel update when the grouped list was not + * queried with `watch`. A channel with a local copy is added from that copy with the update applied. A channel without + * one is skipped and shown when its page loads. 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 watch Whether the grouped list was queried with `watch`. */ -internal fun EventHandlingResult.skipWatchWithoutLocalCopy( +internal fun EventHandlingResult.withoutWatchUnlessListWatches( event: ChatEvent, cachedChannel: Channel?, watch: Boolean, ): EventHandlingResult { if (this !is EventHandlingResult.WatchAndAdd || event !is NotificationChannelPartialUpdatedEvent) return this - return if (cachedChannel == null && !watch) EventHandlingResult.Skip else this + if (watch) return this + return cachedChannel?.mergePartialUpdate(event.channel)?.let(EventHandlingResult::Add) ?: EventHandlingResult.Skip } /** 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 507cdab9f843..648f2b22603c 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 @@ -688,7 +688,7 @@ internal class QueryChannelsLogic( is QueryChannelsIdentifier.Grouped -> result.skipWatchBelowLoadedChannels(event, channel, queryChannelsStateLogic.getState()) // Without a known request, the list is treated as watching, as the reconnect sync does. - .skipWatchWithoutLocalCopy(event, channel, watch = groupedQueryConfig()?.watch ?: true) + .withoutWatchUnlessListWatches(event, channel, watch = groupedQueryConfig()?.watch ?: true) else -> result } } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt index c6f951e1f1b5..09fd001a09d6 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicGroupedWatchTest.kt @@ -29,6 +29,7 @@ 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.GroupedQueryConfig import io.getstream.chat.android.state.plugin.state.querychannels.QueryChannelsState +import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.test.asCall import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -137,17 +138,18 @@ internal class QueryChannelsLogicGroupedWatchTest { } @Test - fun `a partial update watches a stored channel when the list was queried without watch`() = runTest { - givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) - givenGroupedQueryWatch(watch = false) - val event = partialUpdate(createdAt = daysAgo(1)) - val stored = randomChannel(id = event.channelId, type = event.channelType, createdAt = daysAgo(1)) - whenever(queryChannelsDatabaseLogic.selectChannels(listOf(event.cid))) doReturn listOf(stored) + fun `a partial update adds a stored channel without watching it when the list was queried without watch`() = + runTest { + givenLoadedChannels(lastLoadedAt = daysAgo(5), endOfChannels = false) + givenGroupedQueryWatch(watch = false) + val event = partialUpdate(createdAt = daysAgo(1)) + val stored = randomChannel(id = event.channelId, type = event.channelType, createdAt = daysAgo(1)) + whenever(queryChannelsDatabaseLogic.selectChannels(listOf(event.cid))) doReturn listOf(stored) - val results = logic.parseChatEventResults(listOf(event)) + val results = logic.parseChatEventResults(listOf(event)) - assertEquals(listOf(EventHandlingResult.WatchAndAdd(event.cid)), results) - } + assertEquals(listOf(EventHandlingResult.Add(stored.mergePartialUpdate(event.channel))), results) + } @Test fun `a partial update places a channel without a local copy by the last message time of the event`() = runTest { From ff2953be1cc827780d7a24dbb3847f1d75116a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 14:50:01 +0100 Subject: [PATCH 09/16] Wait for the second reconnection in the grouped reconnection e2e test A reconnection soon after the previous one can take longer than the 5 second wait of the next assertion, so the test now waits 3 seconds after going back online, as it does after the first reconnection. --- .../chat/android/compose/tests/GroupedChannelListTests.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt index 5216ff83707f..4bc1b435fc82 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt @@ -84,6 +84,8 @@ class GroupedChannelListTests : StreamTestCase() { } step("AND user goes back online") { device.enableInternetConnection() + // A reconnection soon after the previous one can take longer than the default wait of the next step. + userRobot.sleep(3.seconds) } step("THEN the moved channel, which was on screen, is no longer in the new tab") { userRobot.assertChannelWithName("20", isDisplayed = false) From 3168f606259bc1f26011c7219a965147fb1da0a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 15:04:47 +0100 Subject: [PATCH 10/16] Keep the stored hidden and blocked state on notification.message_new The channel in notification.message_new is shared by all members, so it has no hidden or blocked value. The stored values are now kept after the merge, so a message that does not un-hide the channel keeps it hidden. --- .../internal/EventHandlerSequential.kt | 5 +- .../state/utils/internal/ChannelVisibility.kt | 12 +++++ .../internal/EventHandlerSequentialTest.kt | 52 +++++++++++++++++-- .../utils/internal/ChannelVisibilityTest.kt | 24 +++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) 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 b42287ac6852..e3efb6f9de57 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 @@ -127,6 +127,7 @@ import io.getstream.chat.android.state.plugin.state.global.internal.MutableGloba import io.getstream.chat.android.state.utils.internal.calculateNewLastMessageAt import io.getstream.chat.android.state.utils.internal.mergePartialUpdate import io.getstream.chat.android.state.utils.internal.unhidesChannel +import io.getstream.chat.android.state.utils.internal.withUserStateOf import io.getstream.log.StreamLog import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineExceptionHandler @@ -690,8 +691,8 @@ internal class EventHandlerSequential( } is NotificationMessageNewEvent -> { batch.addMessageData(event.createdAt, event.cid, event.message) - val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + val stored = batch.getCurrentChannel(event.cid) + val channel = stored?.mergeChannelFromEvent(event.channel)?.withUserStateOf(stored) ?: event.channel batch.addChannel(channel.copy(hidden = channel.hiddenAfter(event.message))) } is NotificationAddedToChannelEvent -> { diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt index 0a19b622b415..fdcc542944b5 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/utils/internal/ChannelVisibility.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.state.utils.internal +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageType @@ -40,3 +41,14 @@ internal fun Message.unhidesChannel(channelExtraData: Map, blockedU else -> false } + +/** + * This channel with the current user's `hidden` and `blocked` state taken from [stored] when this channel lacks it. + * Channels in events are shared by all members, so they carry no state of the current user. + * + * @param stored The stored copy of the channel. + */ +internal fun Channel.withUserStateOf(stored: Channel): Channel = copy( + hidden = hidden ?: stored.hidden, + extraData = stored.extraData.filterKeys(BLOCKED_FIELD_NAME::equals) + extraData, +) 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 c8d4da28c7a7..b3bb47ced443 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 @@ -386,9 +386,55 @@ internal class EventHandlerSequentialTest { storedHiddenChannelAfter(message).hidden `should be equal to` false } + @Test + fun `When handling NotificationMessageNewEvent with a campaign message, The stored hidden channel should stay hidden`() = + runTest { + val message = randomMessage( + type = "regular", + shadowed = false, + extraData = mapOf("created_by_campaign_id" to "campaign-id"), + ) + + storedHiddenChannelAfter(message, ::notificationMessageNewIn).hidden `should be equal to` true + } + + @Test + fun `When handling NotificationMessageNewEvent in a blocked channel, The stored hidden channel should stay hidden`() = + runTest { + val message = randomMessage(type = "regular", shadowed = false, extraData = emptyMap()) + val storedChannel = randomChannel(hidden = true, extraData = mapOf("blocked" to true)) + + val result = storedHiddenChannelAfter(message, ::notificationMessageNewIn, storedChannel) + + result.hidden `should be equal to` true + result.extraData["blocked"] `should be equal to` true + } + + @Test + fun `When handling NotificationMessageNewEvent with a regular message, The stored hidden channel should be shown`() = + runTest { + val message = randomMessage(type = "regular", shadowed = false, extraData = emptyMap()) + + storedHiddenChannelAfter(message, ::notificationMessageNewIn).hidden `should be equal to` false + } + + /** A `notification.message_new` event whose channel, like the backend's, carries no state of the current user. */ + private fun notificationMessageNewIn(channel: Channel, message: Message): ChatEvent = + randomNotificationMessageNewEvent( + cid = channel.cid, + channel = Channel(id = channel.id, type = channel.type), + message = message, + ) + + private fun newMessageIn(channel: Channel, message: Message): ChatEvent = + randomNewMessageEvent(cid = channel.cid, message = message) + /** Handles a new [message] in a stored hidden channel and returns the channel as it is stored again. */ - private suspend fun TestScope.storedHiddenChannelAfter(message: Message): Channel { - val storedChannel = randomChannel(hidden = true, extraData = emptyMap()) + private suspend fun TestScope.storedHiddenChannelAfter( + message: Message, + eventIn: (Channel, Message) -> ChatEvent = ::newMessageIn, + storedChannel: Channel = randomChannel(hidden = true, extraData = emptyMap()), + ): Channel { val repos: RepositoryFacade = mock() whenever(repos.selectMessages(any())) doReturn emptyList() whenever(repos.selectChannels(any())) doReturn listOf(storedChannel) @@ -398,7 +444,7 @@ internal class EventHandlerSequentialTest { .withRepositoryFacade(repos) .get(this) - handler.handleEvents(randomNewMessageEvent(cid = storedChannel.cid, message = message)) + handler.handleEvents(eventIn(storedChannel, message)) val inserted = argumentCaptor>() verify(repos).insertChannels(inserted.capture()) diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt index 7ef9ea5a3da4..e67f538456e6 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/utils/internal/ChannelVisibilityTest.kt @@ -18,9 +18,11 @@ package io.getstream.chat.android.state.utils.internal import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -66,6 +68,28 @@ internal class ChannelVisibilityTest { assertFalse(message.unhidesChannel(emptyMap(), listOf(message.user.id))) } + @Test + fun `A channel from an event takes the stored hidden and blocked state`() { + val stored = randomChannel(hidden = true, extraData = mapOf("blocked" to true)) + val fromEvent = randomChannel(hidden = null, extraData = mapOf("group" to "vip")) + + val result = fromEvent.withUserStateOf(stored) + + assertEquals(true, result.hidden) + assertEquals(mapOf("blocked" to true, "group" to "vip"), result.extraData) + } + + @Test + fun `A channel from an event keeps the hidden and blocked state it carries`() { + val stored = randomChannel(hidden = true, extraData = mapOf("blocked" to true)) + val fromEvent = randomChannel(hidden = false, extraData = mapOf("blocked" to false)) + + val result = fromEvent.withUserStateOf(stored) + + assertEquals(false, result.hidden) + assertEquals(mapOf("blocked" to false), result.extraData) + } + private fun message( type: String = MessageType.REGULAR, shadowed: Boolean = false, From c14a274b2349a3ec985f9bd1bf4ebbc42f3259a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 20 Oct 2026 16:01:55 +0100 Subject: [PATCH 11/16] Turn on Wi-Fi before mobile data in the e2e network helper When mobile data connected first, Wi-Fi took over the default network a few seconds later and slowed down the requests the app had in flight, so the reconnect sync could miss the next assertion. Wi-Fi now connects first, and the grouped reconnection test no longer needs its extra wait. --- .../chat/android/compose/tests/GroupedChannelListTests.kt | 2 -- .../io/getstream/chat/android/e2e/test/uiautomator/Actions.kt | 4 +++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt index 4bc1b435fc82..5216ff83707f 100644 --- a/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt +++ b/stream-chat-android-compose-sample/src/androidTestE2eDebug/kotlin/io/getstream/chat/android/compose/tests/GroupedChannelListTests.kt @@ -84,8 +84,6 @@ class GroupedChannelListTests : StreamTestCase() { } step("AND user goes back online") { device.enableInternetConnection() - // A reconnection soon after the previous one can take longer than the default wait of the next step. - userRobot.sleep(3.seconds) } step("THEN the moved channel, which was on screen, is no longer in the new tab") { userRobot.assertChannelWithName("20", isDisplayed = false) diff --git a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt index 5f28ff1e942c..10aa929db2ad 100644 --- a/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt +++ b/stream-chat-android-e2e-test/src/main/kotlin/io/getstream/chat/android/e2e/test/uiautomator/Actions.kt @@ -92,9 +92,11 @@ public fun UiDevice.goToForeground() { } public fun UiDevice.enableInternetConnection() { - executeShellCommand("svc data enable") + // Wi-Fi goes first. When mobile data connects first, Wi-Fi takes over the default network a few seconds later and + // slows down the requests the app has in flight. executeShellCommand("svc wifi enable") waitForInternetConnection() + executeShellCommand("svc data enable") } public fun UiDevice.disableInternetConnection() { From e51088fb5b3673f8747e472fd7161f0e16703bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 10:18:18 +0100 Subject: [PATCH 12/16] Keep the later last message time in the channel state on partial updates The channel state of an active channel now takes the later of its own and the event's last_message_at, like the merged channel the grouped list checks, so both agree on the channel's last activity. --- .../state/utils/internal/ChannelPartialUpdate.kt | 3 ++- .../state/utils/internal/ChannelPartialUpdateTest.kt | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 index 2c1d316a15da..d738e33d6c03 100644 --- 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 @@ -56,7 +56,7 @@ internal fun Channel.mergePartialUpdate(partial: Channel): Channel = copy( /** * Applies the fields carried by [NotificationChannelPartialUpdatedEvent.channel] to this channel data and keeps - * all the others. + * all the others. [ChannelData.lastMessageAt] takes the later of the local and the event value. */ internal fun ChannelData.mergePartialUpdate(partial: Channel): ChannelData = copy( name = partial.name, @@ -65,6 +65,7 @@ internal fun ChannelData.mergePartialUpdate(partial: Channel): ChannelData = cop team = partial.team, createdAt = partial.createdAt, updatedAt = partial.updatedAt, + lastMessageAt = max(lastMessageAt, partial.lastMessageAt), extraData = mergePartialExtraData(current = extraData, partial = partial.extraData), ) 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 index 070fbf7a3703..ad2dc1df68f3 100644 --- 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 @@ -118,4 +118,15 @@ internal class ChannelPartialUpdateTest { assertEquals(local.mergePartialUpdate(partial).toChannelData(), merged) } + + @Test + fun `merging a partial update with a later last message time into channel data matches the channel`() { + val local = randomChannel(lastMessageAt = Date(1_000)) + val laterPartial = partial.copy(lastMessageAt = Date(2_000)) + + val merged = local.toChannelData().mergePartialUpdate(laterPartial) + + assertEquals(Date(2_000), merged.lastMessageAt) + assertEquals(local.mergePartialUpdate(laterPartial).toChannelData(), merged) + } } From 3f2256cb03cb84ef94bacc80f7c64a793da75c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 10:18:18 +0100 Subject: [PATCH 13/16] Do not move group badges for hidden or disabled channels The backend does not count hidden or disabled channels in grouped unread counts, so a group change of such a channel no longer moves a badge. --- .../internal/GroupedUnreadChannelsUpdater.kt | 3 +-- .../internal/GroupedUnreadChannelsUpdaterTest.kt | 16 ++++++++++++++++ .../internal/EventHandlerSequentialTest.kt | 2 ++ 3 files changed, 19 insertions(+), 2 deletions(-) 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 c64f4b70d820..edcb43a9bd2d 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 @@ -28,7 +28,6 @@ import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent import io.getstream.chat.android.client.events.NotificationMessageNewEvent import io.getstream.chat.android.client.extensions.cidToTypeAndId -import io.getstream.chat.android.client.extensions.currentUserUnreadCount import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.GroupedChannels import io.getstream.chat.android.models.UserId @@ -172,7 +171,7 @@ internal class GroupedUnreadChannelsUpdater( private fun hadUnreadFor(cid: String, oldChannel: Channel): Boolean = when { cid in hadUnreadOverride -> hadUnreadOverride.getValue(cid) markAllReadApplied -> false - else -> oldChannel.currentUserUnreadCount(currentUserId) > 0 + else -> oldChannel.isUnreadInGroupFor(currentUserId) } private fun rotateBatchIfNeeded(batchId: Int) { 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 83639ad3cb1c..b3d1c767addb 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 @@ -230,6 +230,21 @@ internal class GroupedUnreadChannelsUpdaterTest { assertEquals(mapOf("a" to 2, "b" to 2), next) } + @Test + fun `NotificationChannelPartialUpdatedEvent for a hidden stored channel returns current map unchanged`() = + runTest { + val stored = channel(group = "a").copy( + hidden = true, + 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 } + + assertSame(current, next) + } + @Test fun `NotificationChannelPartialUpdatedEvent for a channel with no local copy returns current map unchanged`() = runTest { @@ -522,6 +537,7 @@ internal class GroupedUnreadChannelsUpdaterTest { type = CHANNEL_TYPE, extraData = group?.let { mapOf("group" to it) } ?: emptyMap(), read = emptyList(), + hidden = false, ) private fun channelUpdatedEvent(newChannel: Channel): ChannelUpdatedEvent = ChannelUpdatedEvent( 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 b3bb47ced443..52a2c4b9b250 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 @@ -317,6 +317,7 @@ internal class EventHandlerSequentialTest { id = "channel-id", type = "messaging", extraData = mapOf("group" to "a"), + hidden = false, read = listOf(randomChannelUserRead(user = currentUser, unreadMessages = 1)), ) val repos: RepositoryFacade = mock() @@ -890,6 +891,7 @@ internal class EventHandlerSequentialTest { id = channelId, type = channelType, extraData = extraData, + hidden = false, read = listOf( io.getstream.chat.android.randomChannelUserRead( user = currentUser, From 50dddd78bb49c376db2d73d1cc4d583a7e48db46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 10:18:18 +0100 Subject: [PATCH 14/16] Create the channel state for channels that events add to a list A channel that an event adds to a list without active state is now added like a channel from a query result: its state is created and the query is stored, so later events and restarts keep it up to date. A channel with active state is still only tracked, so a stored copy does not replace its live state. --- .../internal/EventHandlerSequential.kt | 9 +++----- .../internal/QueryChannelsLogic.kt | 11 +++++++++ .../internal/QueryChannelsStateLogic.kt | 6 +++++ .../internal/QueryChannelsLogicTest.kt | 23 +++++++++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) 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 e3efb6f9de57..e255def7f77f 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 @@ -315,12 +315,9 @@ internal class EventHandlerSequential( queryChannelsLogic.parseChatEventResults(batchEvent.sortedEvents).forEach { result -> when (result) { is EventHandlingResult.Add -> { - // Use trackChannel instead of addChannel to avoid overwriting the shared - // per-channel state with a potentially stale DB-cached channel. - // Channel events have already updated per-channel state (e.g., lastMessageAt) - // before this method runs, and refreshChannelsState below will reconcile - // the query map with the live per-channel data. - queryChannelsLogic.trackChannel(result.channel) + // Channel events have already updated the state of active channels before this method runs, and + // refreshChannelsState below reconciles the query map with it. + queryChannelsLogic.addChannelFromEvent(result.channel) } is EventHandlingResult.WatchAndAdd -> queryChannelsLogic.watchAndAddChannel(result.cid) is EventHandlingResult.Remove -> queryChannelsLogic.removeChannel(result.cid) 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 648f2b22603c..5d86a5a6f2db 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 @@ -291,6 +291,17 @@ internal class QueryChannelsLogic( queryChannelsStateLogic.trackChannel(channel) } + /** + * Adds [channel], resolved from an event, to the query. A channel with active state is only tracked, so its live + * state is not replaced by a possibly stale copy. Any other channel is added like a channel from a query result, + * which creates its state and stores the query. + * + * @param channel The channel to add. + */ + internal suspend fun addChannelFromEvent(channel: Channel) { + if (queryChannelsStateLogic.isActiveChannel(channel.cid)) trackChannel(channel) else addChannel(channel) + } + /** * Calls watch channel and adds result to the query. A grouped query does not add the channel when it is hidden. * diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index 293c2b5ffb10..16f2ffd30d69 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -306,6 +306,12 @@ internal class QueryChannelsStateLogic( mutableState.setChannels(newChannels) } + /** Whether the channel with [cid] has active in-memory state. */ + internal fun isActiveChannel(cid: String): Boolean { + val (type, id) = cid.cidToTypeAndId() + return stateRegistry.isActiveChannel(type, id) + } + /** * Returns the current [Channel] snapshot from the in-memory per-channel state if the * channel is active, or `null` otherwise. diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt index 36b416270ae1..a65b27d95353 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/querychannels/internal/QueryChannelsLogicTest.kt @@ -592,5 +592,28 @@ internal class QueryChannelsLogicTest { assertEquals(request, logic.currentRequest()) } + @Test + fun `addChannelFromEvent tracks a channel with active state`() = runTest { + val channel = randomChannel() + whenever(queryChannelsStateLogic.isActiveChannel(channel.cid)) doReturn true + + logic.addChannelFromEvent(channel) + + verify(queryChannelsStateLogic).trackChannel(channel) + verify(queryChannelsStateLogic, never()).addChannelsState(any()) + } + + @Test + fun `addChannelFromEvent adds and stores a channel without active state`() = runTest { + val channel = randomChannel() + whenever(queryChannelsStateLogic.isActiveChannel(channel.cid)) doReturn false + + logic.addChannelFromEvent(channel) + + verify(queryChannelsStateLogic).addChannelsState(listOf(channel)) + verify(queryChannelsDatabaseLogic).insertQueryChannels(queryChannelsSpec) + verify(queryChannelsStateLogic, never()).trackChannel(any()) + } + // endregion } From ead6f14d4b7ae7117123ed3b5db7f37c4b8cfede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 10:18:18 +0100 Subject: [PATCH 15/16] Keep the stored hidden and blocked state on channel updates and truncations The channels in channel.updated and channel.truncated events are shared by all members, so they carry no hidden or blocked value. The stored values are now kept, like for notification.message_new, so a later message that does not un-hide the channel keeps it hidden. --- .../internal/EventHandlerSequential.kt | 19 +++++++++---- .../internal/EventHandlerSequentialTest.kt | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) 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 e255def7f77f..c074979f7b45 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 @@ -688,8 +688,8 @@ internal class EventHandlerSequential( } is NotificationMessageNewEvent -> { batch.addMessageData(event.createdAt, event.cid, event.message) - val stored = batch.getCurrentChannel(event.cid) - val channel = stored?.mergeChannelFromEvent(event.channel)?.withUserStateOf(stored) ?: event.channel + val channel = batch.getCurrentChannel(event.cid) + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel.copy(hidden = channel.hiddenAfter(event.message))) } is NotificationAddedToChannelEvent -> { @@ -804,12 +804,12 @@ internal class EventHandlerSequential( } is ChannelUpdatedEvent -> { val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel) } is ChannelUpdatedByUserEvent -> { val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel) } is NotificationChannelPartialUpdatedEvent -> { @@ -825,7 +825,7 @@ internal class EventHandlerSequential( } is ChannelTruncatedEvent -> { val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel) } is NotificationChannelDeletedEvent -> { @@ -835,7 +835,7 @@ internal class EventHandlerSequential( } is NotificationChannelTruncatedEvent -> { val channel = batch.getCurrentChannel(event.cid) - ?.mergeChannelFromEvent(event.channel) ?: event.channel + ?.mergeSharedEventChannel(event.channel) ?: event.channel batch.addChannel(channel) } @@ -958,6 +958,13 @@ internal class EventHandlerSequential( logger.v { "[updateOfflineStorage] completed batchId: ${batchEvent.id}" } } + /** + * This stored channel with the channel of an event applied. The event's channel is shared by all members, so the + * current user's `hidden` and `blocked` state is kept from this channel. + */ + private fun Channel.mergeSharedEventChannel(eventChannel: Channel): Channel = + mergeChannelFromEvent(eventChannel).withUserStateOf(this) + /** The hidden state of this channel after the new [message]. */ private fun Channel.hiddenAfter(message: Message): Boolean? = if (message.unhidesChannel(extraData, mutableGlobalState.blockedUserIds.value)) false else hidden 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 52a2c4b9b250..11e40ae3cae8 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 @@ -95,6 +95,7 @@ import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.util.concurrent.atomic.AtomicReference +@Suppress("LargeClass") @kotlinx.coroutines.ExperimentalCoroutinesApi internal class EventHandlerSequentialTest { @@ -419,6 +420,33 @@ internal class EventHandlerSequentialTest { storedHiddenChannelAfter(message, ::notificationMessageNewIn).hidden `should be equal to` false } + @Test + fun `When handling ChannelUpdatedEvent, The stored hidden and blocked state should be kept`() = runTest { + val storedChannel = randomChannel(hidden = true, extraData = mapOf("blocked" to true, "group" to "a")) + 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) + // Like the backend's, the event's channel carries no state of the current user. + val eventChannel = randomChannel( + id = storedChannel.id, + type = storedChannel.type, + hidden = null, + extraData = mapOf("group" to "b"), + ) + + handler.handleEvents(randomChannelUpdatedEvent(cid = storedChannel.cid, channel = eventChannel)) + + val inserted = argumentCaptor>() + verify(repos).insertChannels(inserted.capture()) + val result = inserted.firstValue.single() + result.hidden `should be equal to` true + result.extraData `should be equal to` mapOf("blocked" to true, "group" to "b") + } + /** A `notification.message_new` event whose channel, like the backend's, carries no state of the current user. */ private fun notificationMessageNewIn(channel: Channel, message: Message): ChatEvent = randomNotificationMessageNewEvent( From 443461137f623ded6aee05e137db3737f1075575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 21 Oct 2026 10:46:12 +0100 Subject: [PATCH 16/16] Reset grouped lists queried with presence after a reconnect The /sync request cannot subscribe to presence, and the grouped query only subscribes the channels it returns. A grouped list queried with presence now goes back to its first page after a reconnect, so all its channels get presence events again. --- .../chat/android/state/sync/internal/SyncManager.kt | 13 +++++++++---- .../chat/android/state/internal/SyncManagerTest.kt | 10 +++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt index 3d97508cbd0c..2375fe50c318 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/sync/internal/SyncManager.kt @@ -532,10 +532,15 @@ internal class SyncManager( .toMap() .takeIf { it.isNotEmpty() } ?.let { groups -> - val keysKeepingPages = activeGroupedLogics - .filter { syncedCids.containsAll(it.cids()) } - .mapNotNull { it.groupKey() } - .toSet() + // `/sync` does not subscribe to presence, so only pages fetched again would get presence events. + val keysKeepingPages = if (shared?.presence == true) { + emptySet() + } else { + activeGroupedLogics + .filter { syncedCids.containsAll(it.cids()) } + .mapNotNull { it.groupKey() } + .toSet() + } ReconnectGroupQueries(groups, keysKeepingPages) } diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt index e8fbf4eca1f8..929ddf2b7ef7 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/internal/SyncManagerTest.kt @@ -1370,6 +1370,13 @@ internal class SyncManagerTest { assertEquals(setOf("all"), keysKeepingPages()) } + @Test + fun `on reconnect a grouped list queried with presence is reset`() = runTest(testDispatcher) { + givenReconnect(listOf(groupedQueryLogic(watch = true, cids = setOf("messaging:a"), presence = true))) + + assertEquals(emptySet(), keysKeepingPages()) + } + @Test fun `on reconnect a grouped list is reset when the sync reported too many events`() = runTest(testDispatcher) { givenReconnect( @@ -1501,6 +1508,7 @@ internal class SyncManagerTest { watch: Boolean, cids: Set = emptySet(), groupKey: String = "all", + presence: Boolean = false, ): QueryChannelsLogic = mock { on(it.groupKey()) doReturn groupKey on(it.currentRequest()) doReturn mock() @@ -1510,7 +1518,7 @@ internal class SyncManagerTest { limit = null, pageSize = null, watch = watch, - presence = false, + presence = presence, ) }