From a569176d6b5423aecce39615c8db925573669e4a Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:06:48 +0000 Subject: [PATCH 01/21] Add local unread count tracking for channels with read events disabled --- .../api/stream-chat-android-client.api | 12 +- .../android/client/api/ChatClientConfig.kt | 6 + .../android/client/api/state/StateRegistry.kt | 5 + .../factory/StreamStatePluginFactory.kt | 1 + .../internal/ChannelMarkReadListenerState.kt | 12 +- .../logic/channel/internal/ChannelLogic.kt | 8 +- .../channel/internal/ChannelLogicImpl.kt | 3 +- .../internal/legacy/ChannelLogicLegacyImpl.kt | 3 +- .../internal/legacy/ChannelStateLogic.kt | 36 +++- .../channel/internal/ChannelStateImpl.kt | 79 +++++++-- .../internal/ChannelStateLegacyImpl.kt | 84 ++++++---- .../state/channel/internal/MarkReadResult.kt | 32 ++++ .../ChannelMarkReadListenerStateTest.kt | 70 ++++++++ .../channel/internal/ChannelLogicImplTest.kt | 11 +- .../internal/legacy/ChannelStateLogicTest.kt | 31 ++++ .../ChannelStateImplLocalUnreadCountTest.kt | 158 ++++++++++++++++++ .../ChannelStateImplReadReceiptsTest.kt | 21 ++- ...nnelStateLegacyImplLocalUnreadCountTest.kt | 89 ++++++++++ .../internal/ChannelStateLegacyImplTest.kt | 24 +-- 19 files changed, 590 insertions(+), 95 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MarkReadResult.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index ea7365557b0..03a802ed7f5 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -356,9 +356,11 @@ public final class io/getstream/chat/android/client/api/ChatClientConfig { public fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;Z)V public fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZ)V public fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;)V - public synthetic fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;Z)V + public synthetic fun (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Z public final fun component10 ()Lio/getstream/chat/android/client/cache/StreamCacheConfig; + public final fun component11 ()Z public final fun component2 ()Ljava/util/Set; public final fun component3 ()Z public final fun component4 ()Z @@ -367,8 +369,8 @@ public final class io/getstream/chat/android/client/api/ChatClientConfig { public final fun component7 ()Lio/getstream/chat/android/client/api/MessageLimitConfig; public final fun component8 ()Z public final fun component9 ()Z - public final fun copy (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;)Lio/getstream/chat/android/client/api/ChatClientConfig; - public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/ChatClientConfig;ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;ILjava/lang/Object;)Lio/getstream/chat/android/client/api/ChatClientConfig; + public final fun copy (ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;Z)Lio/getstream/chat/android/client/api/ChatClientConfig; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/api/ChatClientConfig;ZLjava/util/Set;ZZLio/getstream/chat/android/models/TimeDuration;Lkotlin/jvm/functions/Function0;Lio/getstream/chat/android/client/api/MessageLimitConfig;ZZLio/getstream/chat/android/client/cache/StreamCacheConfig;ZILjava/lang/Object;)Lio/getstream/chat/android/client/api/ChatClientConfig; public fun equals (Ljava/lang/Object;)Z public final fun getCacheConfig ()Lio/getstream/chat/android/client/cache/StreamCacheConfig; public final fun getFastEventParsing ()Z @@ -381,6 +383,7 @@ public final class io/getstream/chat/android/client/api/ChatClientConfig { public final fun getUserPresence ()Z public fun hashCode ()I public final fun isAutomaticSyncOnReconnectEnabled ()Z + public final fun isLocalUnreadCountEnabled ()Z public fun toString ()Ljava/lang/String; } @@ -967,7 +970,8 @@ public final class io/getstream/chat/android/client/api/state/StateRegistry { public fun (Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/Job;Lkotlin/jvm/functions/Function0;Lkotlinx/coroutines/CoroutineScope;Lio/getstream/chat/android/client/api/MessageLimitConfig;)V public fun (Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/Job;Lkotlin/jvm/functions/Function0;Lkotlinx/coroutines/CoroutineScope;Lio/getstream/chat/android/client/api/MessageLimitConfig;Lkotlinx/coroutines/flow/StateFlow;)V public fun (Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/Job;Lkotlin/jvm/functions/Function0;Lkotlinx/coroutines/CoroutineScope;Lio/getstream/chat/android/client/api/MessageLimitConfig;Lkotlinx/coroutines/flow/StateFlow;Z)V - public synthetic fun (Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/Job;Lkotlin/jvm/functions/Function0;Lkotlinx/coroutines/CoroutineScope;Lio/getstream/chat/android/client/api/MessageLimitConfig;Lkotlinx/coroutines/flow/StateFlow;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/Job;Lkotlin/jvm/functions/Function0;Lkotlinx/coroutines/CoroutineScope;Lio/getstream/chat/android/client/api/MessageLimitConfig;Lkotlinx/coroutines/flow/StateFlow;ZZ)V + public synthetic fun (Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/flow/StateFlow;Lkotlinx/coroutines/Job;Lkotlin/jvm/functions/Function0;Lkotlinx/coroutines/CoroutineScope;Lio/getstream/chat/android/client/api/MessageLimitConfig;Lkotlinx/coroutines/flow/StateFlow;ZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun channel (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/chat/android/client/channel/state/ChannelState; public final fun clear ()V public final fun queryChannels (Lio/getstream/chat/android/models/FilterObject;Lio/getstream/chat/android/models/querysort/QuerySorter;)Lio/getstream/chat/android/client/api/state/QueryChannelsState; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatClientConfig.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatClientConfig.kt index 1855a6a8ade..7cfefaf64fc 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatClientConfig.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatClientConfig.kt @@ -89,6 +89,11 @@ import io.getstream.chat.android.models.TimeDuration * * @param cacheConfig Configuration for the SDK's user-configurable on-disk caches. By default all caches are off. See * [StreamCacheConfig] for the available cache types and their options. + * + * @param isLocalUnreadCountEnabled When enabled, the per-channel unread count is tracked on-device for channels where + * server-side read events are disabled ([io.getstream.chat.android.models.Config.readEventsEnabled] is `false`, typical + * for livestream channels): incoming messages increment a local counter and marking the channel as read resets it + * without a network request. Defaults to `false`. */ public data class ChatClientConfig @JvmOverloads constructor( public val offlineEnabled: Boolean = true, @@ -101,6 +106,7 @@ public data class ChatClientConfig @JvmOverloads constructor( public val useLegacyChannelLogic: Boolean = false, public val fastEventParsing: Boolean = false, public val cacheConfig: StreamCacheConfig = StreamCacheConfig(), + public val isLocalUnreadCountEnabled: Boolean = false, ) /** diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt index 7fa5eec382d..b7a1e43d4fa 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/state/StateRegistry.kt @@ -58,6 +58,8 @@ import java.util.concurrent.ConcurrentHashMap * @param messageLimitConfig Configuration for message limits. * @param mutedUsers The current list of muted users. * @param useLegacyChannelState Whether to use the legacy channel state implementation. + * @param isLocalUnreadCountEnabled Whether the per-channel unread count is tracked on-device for channels with + * server-side read events disabled. */ @Suppress("LongParameterList", "TooManyFunctions") public class StateRegistry @JvmOverloads constructor( @@ -70,6 +72,7 @@ public class StateRegistry @JvmOverloads constructor( private val messageLimitConfig: MessageLimitConfig, private val mutedUsers: StateFlow> = MutableStateFlow(emptyList()), private val useLegacyChannelState: Boolean = true, + private val isLocalUnreadCountEnabled: Boolean = false, ) { private val logger by taggedLogger("Chat:StateRegistry") @@ -188,6 +191,7 @@ public class StateRegistry @JvmOverloads constructor( activeLiveLocations = activeLiveLocations, baseMessageLimit = baseMessageLimit, now = now, + isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, ) } @@ -203,6 +207,7 @@ public class StateRegistry @JvmOverloads constructor( mutedUsers = mutedUsers, liveLocations = activeLiveLocations, messageLimit = baseMessageLimit, + isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, ) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt index 214dd5bd3f2..1931a6dee78 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt @@ -111,6 +111,7 @@ public class StreamStatePluginFactory( messageLimitConfig = config.messageLimitConfig, mutedUsers = mutableGlobalState.muted, useLegacyChannelState = config.useLegacyChannelLogic, + isLocalUnreadCountEnabled = config.isLocalUnreadCountEnabled, ) val isQueryingFree = MutableStateFlow(true) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt index 14e94b4c357..e7caf93d860 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.internal.state.plugin.listener.internal import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult import io.getstream.chat.android.client.plugin.listeners.ChannelMarkReadListener import io.getstream.result.Error import io.getstream.result.Result @@ -41,11 +42,12 @@ internal class ChannelMarkReadListenerState(private val logic: LogicRegistry) : * @return [Result] with information if channel should be marked as read. */ override suspend fun onChannelMarkReadPrecondition(channelType: String, channelId: String): Result { - val shouldMarkRead = logic.channel(channelType, channelId).markRead() - return if (shouldMarkRead) { - Result.Success(Unit) - } else { - Result.Failure(Error.GenericError("Can not mark channel as read with channel id: $channelId")) + return when (logic.channel(channelType, channelId).markRead()) { + MarkReadResult.RemoteRequired -> Result.Success(Unit) + MarkReadResult.HandledLocally -> + Result.Failure(Error.GenericError("Channel $channelId marked as read locally")) + MarkReadResult.NotNeeded -> + Result.Failure(Error.GenericError("Can not mark channel as read with channel id: $channelId")) } } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogic.kt index 94a33d3f10e..7a35cd26c17 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogic.kt @@ -19,6 +19,7 @@ package io.getstream.chat.android.client.internal.state.plugin.logic.channel.int import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.channel.ChannelMessagesUpdateLogic import io.getstream.chat.android.client.events.ChatEvent +import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message @@ -170,11 +171,12 @@ internal interface ChannelLogic { fun setRepliedMessage(message: Message?) /** - * Marks channel as read locally. + * Marks the channel as read for the current user. * - * @return The flag to determine if the channel was marked as read locally. + * @return A [MarkReadResult] describing whether the channel should be marked as read remotely, + * was marked as read on-device, or does not need to be marked as read. */ - fun markRead(): Boolean + fun markRead(): MarkReadResult /** * Checks if typing events are enabled for the channel. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt index 43ad7ab4e58..da2ac26a937 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.QueryChannelPaginationRequest import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.toAnyChannelPaginationRequest import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl +import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.models.Channel @@ -244,7 +245,7 @@ internal class ChannelLogicImpl( state.setRepliedMessage(message) } - override fun markRead(): Boolean { + override fun markRead(): MarkReadResult { return state.markRead() } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt index f6fcef82e29..c4f6486b66c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.internal.state.model.querychannels.pagin import io.getstream.chat.android.client.internal.state.model.querychannels.pagination.internal.toAnyChannelPaginationRequest import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateLegacyImpl +import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.models.Channel @@ -199,7 +200,7 @@ internal class ChannelLogicLegacyImpl( stateLogic.setRepliedMessage(message) } - override fun markRead(): Boolean { + override fun markRead(): MarkReadResult { return stateLogic.markRead() } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt index ffe0aaf7b0b..5ec99ff97c5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt @@ -30,6 +30,7 @@ import io.getstream.chat.android.client.internal.state.message.attachments.inter import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.SearchLogic import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.TypingEventPruner import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateLegacyImpl +import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt @@ -598,11 +599,11 @@ internal class ChannelStateLogic( } /** - * Marks channel as read locally. + * Marks the channel as read for the current user. * - * @return The flag to determine if the channel was marked as read locally. + * @return A [MarkReadResult] describing how the mark-read request was handled. */ - fun markRead(): Boolean { + fun markRead(): MarkReadResult { return mutableState.markChannelAsRead() } @@ -932,15 +933,36 @@ internal class ChannelStateLogic( return } // Update the unread count - currentRead?.let { + incrementUnreadCount(currentRead, eventReceivedDate) + processedMessageIds.put(message.id, true) + } + + /** + * Increments the current user's unread count for a newly received message. Creates the read + * state on-the-fly when local tracking is enabled and none exists yet (read-events-disabled + * channels omit it). + */ + private fun incrementUnreadCount(currentRead: ChannelUserRead?, eventReceivedDate: Date) { + if (currentRead != null) { updateRead( - it.copy( + currentRead.copy( lastReceivedEventDate = eventReceivedDate, - unreadMessages = it.unreadMessages.inc(), + unreadMessages = currentRead.unreadMessages.inc(), ), ) + } else if (mutableState.isLocalUnreadCountEnabled && !mutableState.channelConfig.value.readEventsEnabled) { + clientState.user.value?.let { user -> + updateRead( + ChannelUserRead( + user = user, + lastReceivedEventDate = eventReceivedDate, + unreadMessages = 1, + lastRead = Date(0), + lastReadMessageId = null, + ), + ) + } } - processedMessageIds.put(message.id, true) } private fun Message.storePoll() { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index bf8f608eef6..451a440789c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -90,6 +90,7 @@ internal class ChannelStateImpl( private val messageLimit: Int?, val paginationManager: MessagesPaginationManager = MessagesPaginationManagerImpl(), private val attachmentUrlValidator: AttachmentUrlValidator = AttachmentUrlValidator(), + private val isLocalUnreadCountEnabled: Boolean = false, ) : ChannelState { override val cid: String = "$channelType:$channelId" @@ -1113,40 +1114,66 @@ internal class ChannelStateImpl( return } // Update the unread count - currentRead?.let { + incrementUnreadCount(currentRead, eventReceivedDate) + processedMessageIds.put(message.id, true) + } + + /** + * Increments the current user's unread count for a newly received message. Creates the read + * state on-the-fly when local tracking is enabled and none exists yet (read-events-disabled + * channels omit it). + */ + private fun incrementUnreadCount(currentRead: ChannelUserRead?, eventReceivedDate: Date) { + if (currentRead != null) { updateRead( - it.copy( + currentRead.copy( lastReceivedEventDate = eventReceivedDate, - unreadMessages = it.unreadMessages.inc(), + unreadMessages = currentRead.unreadMessages.inc(), ), ) + } else if (isLocalUnreadCountEnabled && !channelConfig.value.readEventsEnabled) { + currentUser.value?.let { user -> + updateRead( + ChannelUserRead( + user = user, + lastReceivedEventDate = eventReceivedDate, + unreadMessages = 1, + lastRead = Date(0), + lastReadMessageId = null, + ), + ) + } } - processedMessageIds.put(message.id, true) } /** - * Marks the channel as read for the current user if the following conditions are met: - * 1. Read events are enabled in the channel configuration. - * 2. There are messages in the channel. - * 3. The last message in the channel is different from the last read message for the current user. + * Marks the channel as read for the current user. * - * @return `true` if the channel was marked as read, `false` otherwise. + * When the channel has server-side read events disabled, the unread count is either reset + * on-device ([MarkReadResult.HandledLocally]) if local tracking is enabled, or the request is ignored + * ([MarkReadResult.NotNeeded]). Otherwise the channel is marked as read remotely + * ([MarkReadResult.RemoteRequired]) when there are unread messages, or left untouched + * ([MarkReadResult.NotNeeded]) when it is already up to date. */ - fun markRead(): Boolean { + fun markRead(): MarkReadResult { if (!channelConfig.value.readEventsEnabled) { - // Ignore request, `read_events = false` - return false + if (!isLocalUnreadCountEnabled) { + // Ignore request, `read_events = false` + return MarkReadResult.NotNeeded + } + markReadLocally() + return MarkReadResult.HandledLocally } val lastMessage = _messages.value.lastOrNull() if (lastMessage == null) { // No messages in the channel, nothing to mark as read - return true + return MarkReadResult.RemoteRequired } val currentUserRead = read.value if (currentUserRead == null) { // No read state for current user, but we can still mark the channel as read, and create a new read state // later when we receive the updated read state from the backend - return true + return MarkReadResult.RemoteRequired } return if (lastMessage.id != currentUserRead.lastReadMessageId) { // The last message is different from the last read message, we can mark the channel as read @@ -1158,10 +1185,30 @@ internal class ChannelStateImpl( _reads.update { current -> current + (updatedRead.getUserId() to updatedRead) } - true + MarkReadResult.RemoteRequired } else { // Already marked up to the latest message - false + MarkReadResult.NotNeeded + } + } + + /** + * Resets the current user's unread count on-device by advancing the read state to the latest + * message without a network request. Local tracking sets [ChannelUserRead.lastReadMessageId] + * itself, unlike the remote path which waits for the server to confirm it. + */ + private fun markReadLocally() { + val currentUserRead = read.value ?: return + val lastMessage = _messages.value.lastOrNull() + val readDate = lastMessage?.getCreatedAtOrDefault(Date()) ?: Date() + val updatedRead = currentUserRead.copy( + lastReceivedEventDate = readDate, + lastRead = readDate, + lastReadMessageId = lastMessage?.id ?: currentUserRead.lastReadMessageId, + unreadMessages = 0, + ) + _reads.update { current -> + current + (updatedRead.getUserId() to updatedRead) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt index 09dcb908279..ae8423925f5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt @@ -69,6 +69,7 @@ internal class ChannelStateLegacyImpl( latestUsers: StateFlow>, activeLiveLocations: StateFlow>, val baseMessageLimit: Int?, + val isLocalUnreadCountEnabled: Boolean = false, private val now: () -> Long, ) : ChannelState { @@ -591,39 +592,62 @@ internal class ChannelStateLegacyImpl( } /** - * Marks channel as read locally if different conditions are met: - * 1. Channel has read events enabled - * 2. Channel has messages not marked as read yet - * 3. Current user is set + * Marks the channel as read for the current user. * - * @return The flag to determine if the channel was marked as read locally. + * When the channel has server-side read events disabled, the unread count is either reset + * on-device ([MarkReadResult.HandledLocally]) if local tracking is enabled, or the request is ignored + * ([MarkReadResult.NotNeeded]). Otherwise the channel is marked as read remotely + * ([MarkReadResult.RemoteRequired]) when there are unread messages, or left untouched + * ([MarkReadResult.NotNeeded]) when it is already up to date. */ - fun markChannelAsRead(): Boolean = messages.value - .takeIf { channelConfig.value.readEventsEnabled } - ?.lastOrNull() - ?.let { lastMessage -> - when (val currentUserRead = read.value) { - null -> true - else -> - // Allow marking as read if: - // 1. The last read message ID differs from the last message, OR - // 2. There are unread messages (even if lastReadMessageId matches, server state may differ) - currentUserRead - .takeIf { it.lastReadMessageId != lastMessage.id || it.unreadMessages > 0 } - ?.let { - upsertReads( - listOf( - it.copy( - lastReceivedEventDate = lastMessage.getCreatedAtOrDefault(Date()), - lastRead = lastMessage.getCreatedAtOrDefault(Date()), - unreadMessages = 0, - ), - ), - ) - true - } + fun markChannelAsRead(): MarkReadResult { + if (!channelConfig.value.readEventsEnabled) { + if (!isLocalUnreadCountEnabled) { + return MarkReadResult.NotNeeded } - } ?: false + markReadLocally() + return MarkReadResult.HandledLocally + } + val lastMessage = messages.value.lastOrNull() ?: return MarkReadResult.NotNeeded + val currentUserRead = read.value ?: return MarkReadResult.RemoteRequired + // Mark as read if the last read message differs from the last message, or there are unread + // messages (server state may differ even when the ids match). + return if (currentUserRead.lastReadMessageId != lastMessage.id || currentUserRead.unreadMessages > 0) { + upsertReads( + listOf( + currentUserRead.copy( + lastReceivedEventDate = lastMessage.getCreatedAtOrDefault(Date()), + lastRead = lastMessage.getCreatedAtOrDefault(Date()), + unreadMessages = 0, + ), + ), + ) + MarkReadResult.RemoteRequired + } else { + MarkReadResult.NotNeeded + } + } + + /** + * Resets the current user's unread count on-device by advancing the read state to the latest + * message without a network request. Local tracking sets [ChannelUserRead.lastReadMessageId] + * itself, unlike the remote path which waits for the server to confirm it. + */ + private fun markReadLocally() { + val currentUserRead = read.value ?: return + val lastMessage = messages.value.lastOrNull() + val readDate = lastMessage?.getCreatedAtOrDefault(Date()) ?: Date() + upsertReads( + listOf( + currentUserRead.copy( + lastReceivedEventDate = readDate, + lastRead = readDate, + lastReadMessageId = lastMessage?.id ?: currentUserRead.lastReadMessageId, + unreadMessages = 0, + ), + ), + ) + } fun removeMessagesBefore(date: Date) { logger.d { "[removeMessagesBefore] date: $date" } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MarkReadResult.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MarkReadResult.kt new file mode 100644 index 00000000000..076e3121c50 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MarkReadResult.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal + +/** + * Outcome of a request to mark a channel as read. + */ +internal sealed interface MarkReadResult { + + /** The channel needs to be marked as read with a remote request. */ + data object RemoteRequired : MarkReadResult + + /** The channel was marked as read on-device; no remote request is needed. */ + data object HandledLocally : MarkReadResult + + /** The channel does not need to be marked as read. */ + data object NotNeeded : MarkReadResult +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt new file mode 100644 index 00000000000..f2d433f8de5 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.plugin.listener.internal + +import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelLogic +import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult +import io.getstream.result.Result +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +internal class ChannelMarkReadListenerStateTest { + + private val channelType = "messaging" + private val channelId = "123" + + @Test + fun `Remote result makes the precondition succeed so the network request proceeds`() = runTest { + val listener = listenerWith(markReadResult = MarkReadResult.RemoteRequired) + // when + val result = listener.onChannelMarkReadPrecondition(channelType, channelId) + // then + assertInstanceOf(Result.Success::class.java, result) + } + + @Test + fun `Local result makes the precondition fail so no network request follows`() = runTest { + val listener = listenerWith(markReadResult = MarkReadResult.HandledLocally) + // when + val result = listener.onChannelMarkReadPrecondition(channelType, channelId) + // then + assertInstanceOf(Result.Failure::class.java, result) + } + + @Test + fun `None result makes the precondition fail`() = runTest { + val listener = listenerWith(markReadResult = MarkReadResult.NotNeeded) + // when + val result = listener.onChannelMarkReadPrecondition(channelType, channelId) + // then + assertInstanceOf(Result.Failure::class.java, result) + } + + private fun listenerWith(markReadResult: MarkReadResult): ChannelMarkReadListenerState { + val channelLogic: ChannelLogic = mock { + on { it.markRead() } doReturn markReadResult + } + val logic: LogicRegistry = mock { + on { it.channel(channelType, channelId) } doReturn channelLogic + } + return ChannelMarkReadListenerState(logic) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt index 723ff69d823..f8a87136653 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt @@ -20,6 +20,7 @@ import io.getstream.chat.android.client.api.models.Pagination import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.channel.ChannelMessagesUpdateLogic import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.ChannelStateImpl +import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MessagesPaginationManager import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.persistance.repository.RepositoryFacade @@ -887,22 +888,22 @@ internal class ChannelLogicImplTest { @Test fun `markRead should delegate to stateImpl and return result`() { // Given - whenever(stateImpl.markRead()).thenReturn(true) + whenever(stateImpl.markRead()).thenReturn(MarkReadResult.RemoteRequired) // When val result = sut.markRead() // Then - assertTrue(result) + assertEquals(MarkReadResult.RemoteRequired, result) verify(stateImpl).markRead() } @Test - fun `markRead should return false when stateImpl returns false`() { + fun `markRead should return the result from stateImpl`() { // Given - whenever(stateImpl.markRead()).thenReturn(false) + whenever(stateImpl.markRead()).thenReturn(MarkReadResult.NotNeeded) // When val result = sut.markRead() // Then - assertFalse(result) + assertEquals(MarkReadResult.NotNeeded, result) } // endregion diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt index 595140e3bca..d12befe7fd5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt @@ -787,6 +787,37 @@ internal class ChannelStateLogicTest { verify(mutableState, times(0)).upsertReads(any()) } + @Test + fun `Given no read state and local unread tracking enabled, When updateCurrentUserRead is called, Then a read is created`() { + // given - no read state, local tracking on, read events disabled server-side + _read.value = null + _channelConfig.value = Config(readEventsEnabled = false) + whenever(mutableState.isLocalUnreadCountEnabled) doReturn true + + val eventDate = Date(20L) + val newMessage = randomMessage( + user = randomUser(id = "anotherUserId"), + createdAt = eventDate, + silent = false, + shadowed = false, + parentId = null, + ) + + val expectedChannelUserRead = ChannelUserRead( + user = user, + lastReceivedEventDate = eventDate, + unreadMessages = 1, + lastRead = Date(0), + lastReadMessageId = null, + ) + + // when + channelStateLogic.updateCurrentUserRead(eventDate, newMessage) + + // then + verify(mutableState).upsertReads(eq(listOf(expectedChannelUserRead))) + } + @Test fun `Given channel is in global mutes, When syncMuteState is called, Then setMuted is called with true`() { // given diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt new file mode 100644 index 00000000000..ff9eb2d5951 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal + +import io.getstream.chat.android.models.ChannelUserRead +import io.getstream.chat.android.models.Config +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomUser +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.util.Date + +/** + * Tests for the on-device unread-count tracking enabled via + * [io.getstream.chat.android.client.api.ChatClientConfig.isLocalUnreadCountEnabled]. + */ +internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() { + + @Test + fun `markRead resets unread count locally when local tracking is enabled and read events are disabled`() = + runTest { + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + state.setMessages(listOf(createMessage(1, timestamp = 5000))) + state.updateRead(createRead(currentUser, unreadMessages = 5, lastRead = Date(1000))) + // when + val result = state.markRead() + // then: handled on-device and the local count is reset + assertEquals(MarkReadResult.HandledLocally, result) + assertEquals(0, state.unreadCount.value) + } + + @Test + fun `markRead is ignored when local tracking is disabled and read events are disabled`() = runTest { + val state = localTrackingState(isLocalUnreadCountEnabled = false) + state.setChannelConfig(Config(readEventsEnabled = false)) + state.setMessages(listOf(createMessage(1, timestamp = 5000))) + state.updateRead(createRead(currentUser, unreadMessages = 5, lastRead = Date(1000))) + // when + val result = state.markRead() + // then: not handled and the count is left untouched + assertEquals(MarkReadResult.NotNeeded, result) + assertEquals(5, state.unreadCount.value) + } + + @Test + fun `markRead uses the remote path when read events are enabled even if local tracking is enabled`() = runTest { + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = true)) + state.setMessages(listOf(createMessage(1, timestamp = 5000))) + state.updateRead( + createRead(currentUser, unreadMessages = 5, lastRead = Date(1000), lastReadMessageId = "old_id"), + ) + // when + val result = state.markRead() + // then: the remote path applies and the count is reset optimistically + assertEquals(MarkReadResult.RemoteRequired, result) + assertEquals(0, state.unreadCount.value) + } + + @Test + fun `updateCurrentUserRead creates a read state and increments when none exists for a local tracking channel`() = + runTest { + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + assertNull(state.read.value) + val message = createMessage(1, user = randomUser(id = "other_user")) + // when + state.updateCurrentUserRead(Date(2000), message) + // then + assertEquals(1, state.read.value?.unreadMessages) + } + + @Test + fun `updateCurrentUserRead does not create a read state for own messages on a local tracking channel`() = runTest { + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + val message = createMessage(1, user = currentUser) + // when + state.updateCurrentUserRead(Date(2000), message) + // then + assertNull(state.read.value) + assertEquals(0, state.unreadCount.value) + } + + @Test + fun `updateCurrentUserRead does not create a read state when local tracking is disabled`() = runTest { + val state = localTrackingState(isLocalUnreadCountEnabled = false) + state.setChannelConfig(Config(readEventsEnabled = false)) + val message = createMessage(1, user = randomUser(id = "other_user")) + // when + state.updateCurrentUserRead(Date(2000), message) + // then + assertNull(state.read.value) + } + + @Test + fun `local tracking counts only messages received after a mark read`() = runTest { + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + val otherUser = randomUser(id = "other_user") + // first message arrives -> unread = 1 + val firstMessage = createMessage(1, timestamp = 1000, user = otherUser) + state.setMessages(listOf(firstMessage)) + state.updateCurrentUserRead(Date(1000), firstMessage) + assertEquals(1, state.unreadCount.value) + // user opens the channel -> local mark read resets to 0 + state.markRead() + assertEquals(0, state.unreadCount.value) + // a later message arrives -> unread = 1 again, not 2 + val secondMessage = createMessage(2, timestamp = 5000, user = otherUser) + state.setMessages(listOf(firstMessage, secondMessage)) + state.updateCurrentUserRead(Date(5000), secondMessage) + assertEquals(1, state.unreadCount.value) + } + + private fun localTrackingState(isLocalUnreadCountEnabled: Boolean = true) = ChannelStateImpl( + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + currentUser = userFlow, + latestUsers = MutableStateFlow(mapOf(currentUser.id to currentUser)), + mutedUsers = MutableStateFlow(emptyList()), + liveLocations = MutableStateFlow(emptyList()), + messageLimit = null, + isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, + ) + + private fun createRead( + user: User, + unreadMessages: Int, + lastRead: Date, + lastReceivedEventDate: Date = lastRead, + lastReadMessageId: String? = null, + ): ChannelUserRead = ChannelUserRead( + user = user, + lastReceivedEventDate = lastReceivedEventDate, + unreadMessages = unreadMessages, + lastRead = lastRead, + lastReadMessageId = lastReadMessageId, + ) +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt index 58b8969dc66..ee2d60411a5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplReadReceiptsTest.kt @@ -25,7 +25,6 @@ import io.getstream.chat.android.randomUser import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue @@ -487,39 +486,39 @@ internal class ChannelStateImplReadReceiptsTest : ChannelStateImplTestBase() { inner class MarkRead { @Test - fun `markRead should return false when read events are disabled`() = runTest { + fun `markRead returns None when read events are disabled`() = runTest { // given channelState.setChannelConfig(Config(readEventsEnabled = false)) channelState.setMessages(listOf(createMessage(1))) // when val result = channelState.markRead() // then - assertFalse(result) + assertEquals(MarkReadResult.NotNeeded, result) } @Test - fun `markRead should return true when no messages exist`() = runTest { + fun `markRead returns Remote when no messages exist`() = runTest { // given channelState.setChannelConfig(Config(readEventsEnabled = true)) // when val result = channelState.markRead() // then - assertTrue(result) + assertEquals(MarkReadResult.RemoteRequired, result) } @Test - fun `markRead should return true when no read state for current user`() = runTest { + fun `markRead returns Remote when no read state for current user`() = runTest { // given channelState.setChannelConfig(Config(readEventsEnabled = true)) channelState.setMessages(listOf(createMessage(1))) // when val result = channelState.markRead() // then - assertTrue(result) + assertEquals(MarkReadResult.RemoteRequired, result) } @Test - fun `markRead should mark as read and reset unread count`() = runTest { + fun `markRead marks as read remotely and resets unread count`() = runTest { // given channelState.setChannelConfig(Config(readEventsEnabled = true)) val message = createMessage(1, timestamp = 5000) @@ -534,12 +533,12 @@ internal class ChannelStateImplReadReceiptsTest : ChannelStateImplTestBase() { // when val result = channelState.markRead() // then - assertTrue(result) + assertEquals(MarkReadResult.RemoteRequired, result) assertEquals(0, channelState.unreadCount.value) } @Test - fun `markRead should return false when last message is already read`() = runTest { + fun `markRead returns None when last message is already read`() = runTest { // given channelState.setChannelConfig(Config(readEventsEnabled = true)) val message = createMessage(1) @@ -554,7 +553,7 @@ internal class ChannelStateImplReadReceiptsTest : ChannelStateImplTestBase() { // when val result = channelState.markRead() // then - assertFalse(result) + assertEquals(MarkReadResult.NotNeeded, result) } @Test diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt new file mode 100644 index 00000000000..0998f35098e --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal + +import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomChannelUserRead +import io.getstream.chat.android.randomConfig +import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.test.TestCoroutineExtension +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +/** + * Tests for on-device unread tracking on the legacy channel state implementation. + */ +internal class ChannelStateLegacyImplLocalUnreadCountTest { + + private val userFlow = MutableStateFlow(currentUser) + + @Test + fun `markChannelAsRead resets the count locally when local tracking is enabled and read events are disabled`() = + runTest { + val state = localTrackingState() + state.setMessages(listOf(randomMessage(parentId = null, shadowed = false))) + state.setChannelConfig(randomConfig(readEventsEnabled = false)) + state.upsertReads(listOf(randomChannelUserRead(user = currentUser, unreadMessages = 3))) + // when + val result = state.markChannelAsRead() + // then + assertEquals(MarkReadResult.HandledLocally, result) + assertEquals(0, state.read.value?.unreadMessages) + } + + @Test + fun `markChannelAsRead is ignored when local tracking is disabled and read events are disabled`() = runTest { + val state = localTrackingState(isLocalUnreadCountEnabled = false) + state.setMessages(listOf(randomMessage(parentId = null, shadowed = false))) + state.setChannelConfig(randomConfig(readEventsEnabled = false)) + state.upsertReads(listOf(randomChannelUserRead(user = currentUser, unreadMessages = 3))) + // when + val result = state.markChannelAsRead() + // then: not handled and the count is left untouched + assertEquals(MarkReadResult.NotNeeded, result) + assertEquals(3, state.read.value?.unreadMessages) + } + + private fun localTrackingState(isLocalUnreadCountEnabled: Boolean = true) = ChannelStateLegacyImpl( + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + userFlow = userFlow, + latestUsers = MutableStateFlow(mapOf(currentUser.id to currentUser)), + activeLiveLocations = MutableStateFlow(emptyList()), + baseMessageLimit = null, + isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, + now = ::currentTime, + ) + + private companion object { + @JvmField + @RegisterExtension + val testCoroutines = TestCoroutineExtension() + + const val CHANNEL_TYPE = "messaging" + const val CHANNEL_ID = "123" + + val currentUser = User(id = "tom", name = "Tom") + + @OptIn(ExperimentalCoroutinesApi::class) + fun currentTime() = testCoroutines.dispatcher.scheduler.currentTime + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt index 367b635b423..c6b21f047e4 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplTest.kt @@ -617,7 +617,7 @@ internal class ChannelStateLegacyImplTest { } @Test - fun `markChannelAsRead should return true when lastReadMessageId matches lastMessage but unreadMessages greater than 0`() { + fun `markChannelAsRead returns Remote when lastReadMessageId matches lastMessage but unreadMessages greater than 0`() { val lastMessage = randomMessage( parentId = null, shadowed = false, @@ -636,14 +636,14 @@ internal class ChannelStateLegacyImplTest { val actual = channelState.markChannelAsRead() - assertEquals(true, actual) + assertEquals(MarkReadResult.RemoteRequired, actual) assertEquals(lastMessage.createdLocallyAt, channelState.read.value?.lastReceivedEventDate) assertEquals(lastMessage.createdLocallyAt, channelState.read.value?.lastRead) assertEquals(0, channelState.read.value?.unreadMessages) } @Test - fun `markChannelAsRead should return false when lastReadMessageId matches and unreadMessages is 0`() = + fun `markChannelAsRead returns None when lastReadMessageId matches and unreadMessages is 0`() = runTest { val lastMessage = randomMessage( parentId = null, @@ -661,11 +661,11 @@ internal class ChannelStateLegacyImplTest { val result = channelState.markChannelAsRead() - assertEquals(false, result) + assertEquals(MarkReadResult.NotNeeded, result) } @Test - fun `markChannelAsRead should return true when lastReadMessageId differs from lastMessage`() = runTest { + fun `markChannelAsRead returns Remote when lastReadMessageId differs from lastMessage`() = runTest { val lastMessage = randomMessage( parentId = null, shadowed = false, @@ -682,14 +682,14 @@ internal class ChannelStateLegacyImplTest { val result = channelState.markChannelAsRead() - assertEquals(true, result) + assertEquals(MarkReadResult.RemoteRequired, result) assertEquals(lastMessage.createdLocallyAt, channelState.read.value?.lastReceivedEventDate) assertEquals(lastMessage.createdLocallyAt, channelState.read.value?.lastRead) assertEquals(0, channelState.read.value?.unreadMessages) } @Test - fun `markChannelAsRead should return false when readEventsEnabled is false`() = runTest { + fun `markChannelAsRead returns None when readEventsEnabled is false`() = runTest { val lastMessage = randomMessage( parentId = null, shadowed = false, @@ -705,11 +705,11 @@ internal class ChannelStateLegacyImplTest { val result = channelState.markChannelAsRead() - assertEquals(false, result) + assertEquals(MarkReadResult.NotNeeded, result) } @Test - fun `markChannelAsRead should return false when there are no messages`() = runTest { + fun `markChannelAsRead returns None when there are no messages`() = runTest { channelState.setMessages(emptyList()) channelState.setChannelConfig(randomConfig(readEventsEnabled = true)) @@ -721,11 +721,11 @@ internal class ChannelStateLegacyImplTest { val result = channelState.markChannelAsRead() - assertEquals(false, result) + assertEquals(MarkReadResult.NotNeeded, result) } @Test - fun `markChannelAsRead should return true when read state is null`() = runTest { + fun `markChannelAsRead returns Remote when read state is null`() = runTest { val lastMessage = randomMessage( parentId = null, shadowed = false, @@ -739,7 +739,7 @@ internal class ChannelStateLegacyImplTest { val result = channelState.markChannelAsRead() - assertEquals(true, result) + assertEquals(MarkReadResult.RemoteRequired, result) } @Test From b992a97b24c1dffefe961768de6f094d3e228f74 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 07:58:14 +0000 Subject: [PATCH 02/21] Persist locally tracked unread count for channels with read events disabled --- .../internal/DatabaseChannelRepository.kt | 24 ++++++ .../internal/EventHandlerSequential.kt | 21 ++++- .../channel/internal/ChannelLogicImpl.kt | 22 ++++- .../internal/legacy/ChannelLogicLegacyImpl.kt | 13 ++- .../internal/legacy/ChannelStateLogic.kt | 2 + .../channel/internal/ChannelStateImpl.kt | 4 +- .../internal/ChannelStateLegacyImpl.kt | 2 +- .../internal/ChannelRepositoryImplTest.kt | 80 +++++++++++++++++++ .../channel/internal/ChannelLogicImplTest.kt | 24 ++++++ 9 files changed, 187 insertions(+), 5 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt index 8f44ed39fc9..99818417a06 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt @@ -21,10 +21,12 @@ import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.extensions.syncUnreadCountWithReads import io.getstream.chat.android.client.internal.offline.extensions.launchWithMutex +import io.getstream.chat.android.client.internal.offline.repository.domain.channel.userread.internal.toModel import io.getstream.chat.android.client.persistance.repository.ChannelRepository import io.getstream.chat.android.client.utils.message.isPinned import io.getstream.chat.android.core.utils.date.minOf import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message @@ -66,6 +68,7 @@ internal class DatabaseChannelRepository( if (channels.isEmpty()) return val updatedChannels = channels .map { channelCache[it.cid]?.let { cachedChannel -> it.combine(cachedChannel) } ?: it } + .map { if (it.config.readEventsEnabled) it else it.preserveLocallyTrackedReads() } val channelToInsert = updatedChannels .filter { channelCache[it.cid] != it } .map { it.toEntity() } @@ -81,6 +84,27 @@ internal class DatabaseChannelRepository( } } + /** + * For channels with server-side read events disabled the per-channel unread count is tracked + * on-device, so a stored read with a newer [ChannelUserRead.lastReceivedEventDate] must not be + * overwritten by a stale server payload. Mirrors the in-memory channel-state merge, but also + * survives a cache miss (e.g. after a process restart) by falling back to the persisted row. + */ + private suspend fun Channel.preserveLocallyTrackedReads(): Channel { + val storedReads = channelCache[cid]?.read + ?: channelDao.select(cid)?.reads?.values?.map { it.toModel(getUser) } + ?: return this + if (storedReads.isEmpty()) return this + val mergedByUser = read.associateBy(ChannelUserRead::getUserId).toMutableMap() + storedReads.forEach { stored -> + val incoming = mergedByUser[stored.getUserId()] + if (incoming == null || stored.lastReceivedEventDate.after(incoming.lastReceivedEventDate)) { + mergedByUser[stored.getUserId()] = stored + } + } + return copy(read = mergedByUser.values.toList()).syncUnreadCountWithReads() + } + private fun cacheChannel(vararg channels: Channel) { channels.forEach { channelCache.put(it.cid, it) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index 6eb32d267f9..c43844ef05f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -103,6 +103,7 @@ import io.getstream.chat.android.client.extensions.internal.updateMembership import io.getstream.chat.android.client.extensions.internal.updateMembershipBanned import io.getstream.chat.android.client.extensions.internal.updateParentOrReply import io.getstream.chat.android.client.extensions.internal.updateReads +import io.getstream.chat.android.client.extensions.syncUnreadCountWithReads import io.getstream.chat.android.client.internal.state.event.handler.grouped.internal.GroupedUnreadChannelsUpdater import io.getstream.chat.android.client.internal.state.event.handler.internal.batch.BatchEvent import io.getstream.chat.android.client.internal.state.event.handler.internal.batch.SocketEventCollector @@ -115,10 +116,13 @@ import io.getstream.chat.android.client.internal.state.plugin.state.global.inter import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt +import io.getstream.chat.android.client.utils.internal.ChannelId 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.ChannelUserRead import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageReminder @@ -641,7 +645,7 @@ internal class EventHandlerSequential( messages = channel.messages + listOf(enrichedMessage), messageCount = event.channelMessageCount ?: channel.messageCount, lastMessageAt = newLastMessageAt, - ) + ).withLocallyTrackedReads(event.cid) batch.addChannel(updatedChannel) // Update thread data in DB if the new message is added to a thread batch.addThreadIfExists(enrichedMessage) @@ -971,6 +975,21 @@ internal class EventHandlerSequential( } } + /** + * For channels with server-side read events disabled, the per-channel unread count is tracked + * on-device in the channel state. This carries the current in-memory reads into the channel + * being persisted so the count survives a restart. No-op for regular channels and for channels + * that are not currently active in state. + */ + private fun Channel.withLocallyTrackedReads(cid: String): Channel { + if (config.readEventsEnabled) return this + val channelId = ChannelId.fromCid(cid) ?: return this + if (!stateRegistry.isActiveChannel(channelId)) return this + val stateReads = stateRegistry.channel(channelId).reads.value + if (stateReads.isEmpty()) return this + return copy(read = (stateReads + read).distinctBy(ChannelUserRead::getUserId)).syncUnreadCountWithReads() + } + private suspend fun deleteMessagesFromUser(cid: String?, userId: String, hard: Boolean, deletedAt: Date) { val messages = if (cid != null) { // Delete messages only in the specified channel diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt index da2ac26a937..4bde7288428 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt @@ -42,6 +42,7 @@ import io.getstream.chat.android.models.toChannelData import io.getstream.result.Result import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.Date @@ -246,7 +247,26 @@ internal class ChannelLogicImpl( } override fun markRead(): MarkReadResult { - return state.markRead() + val result = state.markRead() + if (result == MarkReadResult.HandledLocally) { + // Local mark-read only mutates in-memory state; persist the reset read so the on-device + // unread count survives a restart. + persistCurrentReads() + } + return result + } + + /** + * Persists the current in-memory reads to the database. Used by the on-device unread tracking + * path, where the read state is not backed by a server response. + */ + private fun persistCurrentReads() { + val reads = state.reads.value + coroutineScope.launch { + repository.selectChannel(state.cid)?.let { channel -> + repository.insertChannel(channel.copy(read = reads)) + } + } } override fun typingEventsEnabled(): Boolean { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt index c4f6486b66c..213e6cbc280 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt @@ -201,7 +201,18 @@ internal class ChannelLogicLegacyImpl( } override fun markRead(): MarkReadResult { - return stateLogic.markRead() + val result = stateLogic.markRead() + if (result == MarkReadResult.HandledLocally) { + // Local mark-read only mutates in-memory state; persist the reset read so the on-device + // unread count survives a restart. + val reads = mutableState.reads.value + coroutineScope.launch { + repos.selectChannel(mutableState.cid)?.let { channel -> + repos.insertChannel(channel.copy(read = reads)) + } + } + } + return result } override fun typingEventsEnabled(): Boolean { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt index 5ec99ff97c5..51b71edaf90 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt @@ -957,6 +957,8 @@ internal class ChannelStateLogic( user = user, lastReceivedEventDate = eventReceivedDate, unreadMessages = 1, + // Only the count is tracked locally; lastRead/lastReadMessageId are left unset + // because there is no server read to anchor the unread separator to. lastRead = Date(0), lastReadMessageId = null, ), diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index 451a440789c..4815f015bf5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -1138,6 +1138,8 @@ internal class ChannelStateImpl( user = user, lastReceivedEventDate = eventReceivedDate, unreadMessages = 1, + // Only the count is tracked locally; lastRead/lastReadMessageId are left unset + // because there is no server read to anchor the unread separator to. lastRead = Date(0), lastReadMessageId = null, ), @@ -1202,7 +1204,7 @@ internal class ChannelStateImpl( val lastMessage = _messages.value.lastOrNull() val readDate = lastMessage?.getCreatedAtOrDefault(Date()) ?: Date() val updatedRead = currentUserRead.copy( - lastReceivedEventDate = readDate, + lastReceivedEventDate = maxOf(currentUserRead.lastReceivedEventDate, readDate), lastRead = readDate, lastReadMessageId = lastMessage?.id ?: currentUserRead.lastReadMessageId, unreadMessages = 0, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt index ae8423925f5..dc4c10e17cd 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt @@ -640,7 +640,7 @@ internal class ChannelStateLegacyImpl( upsertReads( listOf( currentUserRead.copy( - lastReceivedEventDate = readDate, + lastReceivedEventDate = maxOf(currentUserRead.lastReceivedEventDate, readDate), lastRead = readDate, lastReadMessageId = lastMessage?.id ?: currentUserRead.lastReadMessageId, unreadMessages = 0, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt index 03fbd46d7af..4cd9e4176dd 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt @@ -19,16 +19,20 @@ package io.getstream.chat.android.client.internal.offline.repository.domain.chan import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.MockChatClientBuilder import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomChannelUserRead +import io.getstream.chat.android.randomConfig import io.getstream.chat.android.randomDraftMessageOrNull import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomUser import io.getstream.chat.android.test.TestCoroutineRule import kotlinx.coroutines.test.runTest import org.junit.Rule +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -76,4 +80,80 @@ internal class ChannelRepositoryImplTest { verify(channelDao, never()).insert(any()) } + + @Test + fun `insertChannels preserves a newer stored read for a read-events-disabled channel`() = runTest { + val repo = repositoryWithUserEchoingIds() + val cid = "messaging:local" + // Stored on disk: a locally-tracked read with a recent event date and a non-zero count. + val stored = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead(user = currentUser, unreadMessages = 5, lastReceivedEventDate = Date(2000)), + ), + ) + whenever(channelDao.select(cid)) doReturn stored.toEntity() + // Server sends a stale read with count 0. + val serverPayload = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead(user = currentUser, unreadMessages = 0, lastReceivedEventDate = Date(1000)), + ), + ) + + repo.insertChannels(listOf(serverPayload)) + + assertEquals(5, persistedUnreadCount(cid)) + } + + @Test + fun `insertChannels lets the server read win for a read-events-enabled channel`() = runTest { + val repo = repositoryWithUserEchoingIds() + val cid = "messaging:regular" + val stored = randomChannel( + id = "regular", + type = "messaging", + config = randomConfig(readEventsEnabled = true), + read = listOf( + randomChannelUserRead(user = currentUser, unreadMessages = 5, lastReceivedEventDate = Date(2000)), + ), + ) + whenever(channelDao.select(cid)) doReturn stored.toEntity() + val serverPayload = randomChannel( + id = "regular", + type = "messaging", + config = randomConfig(readEventsEnabled = true), + read = listOf( + randomChannelUserRead(user = currentUser, unreadMessages = 0, lastReceivedEventDate = Date(1000)), + ), + ) + + repo.insertChannels(listOf(serverPayload)) + + assertEquals(0, persistedUnreadCount(cid)) + } + + private val currentUser = randomUser(id = "current-user") + + private fun repositoryWithUserEchoingIds(): DatabaseChannelRepository = + DatabaseChannelRepository( + testCoroutines.scope, + channelDao, + { userId -> randomUser(id = userId) }, + { randomMessage() }, + { randomDraftMessageOrNull() }, + ) + + /** Returns the current user's unread count from the entity written to the DAO for [cid]. */ + private suspend fun persistedUnreadCount(cid: String): Int? { + val captor = argumentCaptor>() + verify(channelDao).insertMany(captor.capture()) + return captor.allValues.flatten() + .firstOrNull { it.cid == cid } + ?.reads?.get(currentUser.id)?.unreadMessages + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt index f8a87136653..6764437f700 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt @@ -906,6 +906,30 @@ internal class ChannelLogicImplTest { assertEquals(MarkReadResult.NotNeeded, result) } + @Test + fun `markRead persists the reset read when handled locally`() = runTest { + // Given + val reads = listOf(randomChannelUserRead()) + whenever(stateImpl.markRead()).thenReturn(MarkReadResult.HandledLocally) + whenever(stateImpl.cid).thenReturn(cid) + whenever(stateImpl.reads).thenReturn(MutableStateFlow(reads)) + whenever(repository.selectChannel(cid)).thenReturn(randomChannel()) + // When + sut.markRead() + // Then + verify(repository).insertChannel(any()) + } + + @Test + fun `markRead does not persist when not handled locally`() = runTest { + // Given + whenever(stateImpl.markRead()).thenReturn(MarkReadResult.RemoteRequired) + // When + sut.markRead() + // Then + verify(repository, never()).insertChannel(any()) + } + // endregion // region typingEventsEnabled From 44097327dd51d336ab0209e3b5d7fed67c80ee84 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:16:52 +0000 Subject: [PATCH 03/21] client: Guard locally tracked reads against server payload overwrites --- .../channel/internal/ChannelLogicImpl.kt | 8 +- .../internal/legacy/ChannelLogicLegacyImpl.kt | 13 +- .../internal/legacy/ChannelStateLogic.kt | 64 +++++-- .../channel/internal/ChannelStateImpl.kt | 66 ++++++-- .../internal/legacy/ChannelStateLogicTest.kt | 112 ++++++++++++ .../ChannelStateImplLocalUnreadCountTest.kt | 159 ++++++++++++++++++ 6 files changed, 382 insertions(+), 40 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt index 4bde7288428..c5fbc69e255 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt @@ -34,6 +34,7 @@ import io.getstream.chat.android.client.internal.state.plugin.state.channel.inte import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PendingMessage @@ -258,13 +259,16 @@ internal class ChannelLogicImpl( /** * Persists the current in-memory reads to the database. Used by the on-device unread tracking - * path, where the read state is not backed by a server response. + * path, where the read state is not backed by a server response. The in-memory reads are merged + * over the stored ones so a partially populated state never drops reads already persisted. */ private fun persistCurrentReads() { val reads = state.reads.value + if (reads.isEmpty()) return coroutineScope.launch { repository.selectChannel(state.cid)?.let { channel -> - repository.insertChannel(channel.copy(read = reads)) + val mergedReads = (reads + channel.read).distinctBy(ChannelUserRead::getUserId) + repository.insertChannel(channel.copy(read = mergedReads)) } } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt index 213e6cbc280..435cccbf269 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt @@ -34,6 +34,7 @@ import io.getstream.chat.android.client.internal.state.plugin.state.channel.inte import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PushPreference @@ -204,11 +205,15 @@ internal class ChannelLogicLegacyImpl( val result = stateLogic.markRead() if (result == MarkReadResult.HandledLocally) { // Local mark-read only mutates in-memory state; persist the reset read so the on-device - // unread count survives a restart. + // unread count survives a restart. The in-memory reads are merged over the stored ones + // so a partially populated state never drops reads already persisted. val reads = mutableState.reads.value - coroutineScope.launch { - repos.selectChannel(mutableState.cid)?.let { channel -> - repos.insertChannel(channel.copy(read = reads)) + if (reads.isNotEmpty()) { + coroutineScope.launch { + repos.selectChannel(mutableState.cid)?.let { channel -> + val mergedReads = (reads + channel.read).distinctBy(ChannelUserRead::getUserId) + repos.insertChannel(channel.copy(read = mergedReads)) + } } } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt index 51b71edaf90..3b43d559b30 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt @@ -82,6 +82,13 @@ internal class ChannelStateLogic( private val logger by taggedLogger(TAG) private val processedMessageIds = LruCache(CACHE_SIZE) + /** + * `true` if the per-channel unread count is tracked locally for this channel, i.e. local unread count tracking + * is enabled and read events are disabled server-side. + */ + private val isReadTrackedLocally: Boolean + get() = mutableState.isLocalUnreadCountEnabled && !mutableState.channelConfig.value.readEventsEnabled + /** * Used to prune stale active typing events when the sender * of these events was unable to send a stop typing event. @@ -227,6 +234,24 @@ internal class ChannelStateLogic( localRead: ChannelUserRead, serverRead: ChannelUserRead, ): ChannelUserRead { + if (isReadTrackedLocally) { + // The unread count is tracked locally for this channel (read events are disabled server-side), + // so the server data must never overwrite the locally tracked read state. Note that a recency + // check is not enough: server reads carry lastReceivedEventDate = last_message_at, which ties + // with the local value anchored to the same message. Only the user info and the delivered + // fields are merged from the server. + logger.d { + "[updateReads] Read state is tracked locally, preserving: " + + "local.lastRead=${localRead.lastRead}, " + + "local.unreadMessages=${localRead.unreadMessages}, " + + "server.unreadMessages=${serverRead.unreadMessages}" + } + return localRead.copy( + user = serverRead.user, + lastDeliveredAt = serverRead.lastDeliveredAt ?: localRead.lastDeliveredAt, + lastDeliveredMessageId = serverRead.lastDeliveredMessageId ?: localRead.lastDeliveredMessageId, + ) + } return if (localRead.lastReceivedEventDate.after(serverRead.lastReceivedEventDate)) { // Local state is more recent, preserve it but merge other fields from server logger.d { @@ -941,29 +966,34 @@ internal class ChannelStateLogic( * Increments the current user's unread count for a newly received message. Creates the read * state on-the-fly when local tracking is enabled and none exists yet (read-events-disabled * channels omit it). + * + * The updated read is upserted directly, bypassing the merge in [updateReads] which is reserved + * for server data - the outdated event check in [updateCurrentUserRead] already guarantees this + * update is the most recent one. */ private fun incrementUnreadCount(currentRead: ChannelUserRead?, eventReceivedDate: Date) { - if (currentRead != null) { - updateRead( - currentRead.copy( - lastReceivedEventDate = eventReceivedDate, - unreadMessages = currentRead.unreadMessages.inc(), - ), + val updatedRead = if (currentRead != null) { + currentRead.copy( + lastReceivedEventDate = eventReceivedDate, + unreadMessages = currentRead.unreadMessages.inc(), ) - } else if (mutableState.isLocalUnreadCountEnabled && !mutableState.channelConfig.value.readEventsEnabled) { + } else if (isReadTrackedLocally) { clientState.user.value?.let { user -> - updateRead( - ChannelUserRead( - user = user, - lastReceivedEventDate = eventReceivedDate, - unreadMessages = 1, - // Only the count is tracked locally; lastRead/lastReadMessageId are left unset - // because there is no server read to anchor the unread separator to. - lastRead = Date(0), - lastReadMessageId = null, - ), + ChannelUserRead( + user = user, + lastReceivedEventDate = eventReceivedDate, + unreadMessages = 1, + // Only the count is tracked locally; lastRead/lastReadMessageId are left unset + // because there is no server read to anchor the unread separator to. + lastRead = Date(0), + lastReadMessageId = null, ) } + } else { + null + } + updatedRead?.let { newRead -> + mutableState.upsertReads(listOf(newRead)) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index 4815f015bf5..8dbcccebc84 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -1118,33 +1118,47 @@ internal class ChannelStateImpl( processedMessageIds.put(message.id, true) } + /** + * `true` if the per-channel unread count is tracked locally for this channel, i.e. local unread count tracking + * is enabled and read events are disabled server-side. + */ + private val isReadTrackedLocally: Boolean + get() = isLocalUnreadCountEnabled && !channelConfig.value.readEventsEnabled + /** * Increments the current user's unread count for a newly received message. Creates the read * state on-the-fly when local tracking is enabled and none exists yet (read-events-disabled * channels omit it). + * + * The updated read is upserted directly, bypassing the merge in [updateReads] which is reserved + * for server data - the outdated event check in [updateCurrentUserRead] already guarantees this + * update is the most recent one. */ private fun incrementUnreadCount(currentRead: ChannelUserRead?, eventReceivedDate: Date) { - if (currentRead != null) { - updateRead( - currentRead.copy( - lastReceivedEventDate = eventReceivedDate, - unreadMessages = currentRead.unreadMessages.inc(), - ), + val updatedRead = if (currentRead != null) { + currentRead.copy( + lastReceivedEventDate = eventReceivedDate, + unreadMessages = currentRead.unreadMessages.inc(), ) - } else if (isLocalUnreadCountEnabled && !channelConfig.value.readEventsEnabled) { + } else if (isReadTrackedLocally) { currentUser.value?.let { user -> - updateRead( - ChannelUserRead( - user = user, - lastReceivedEventDate = eventReceivedDate, - unreadMessages = 1, - // Only the count is tracked locally; lastRead/lastReadMessageId are left unset - // because there is no server read to anchor the unread separator to. - lastRead = Date(0), - lastReadMessageId = null, - ), + ChannelUserRead( + user = user, + lastReceivedEventDate = eventReceivedDate, + unreadMessages = 1, + // Only the count is tracked locally; lastRead/lastReadMessageId are left unset + // because there is no server read to anchor the unread separator to. + lastRead = Date(0), + lastReadMessageId = null, ) } + } else { + null + } + updatedRead?.let { newRead -> + _reads.update { current -> + current + (newRead.getUserId() to newRead) + } } } @@ -1663,6 +1677,24 @@ internal class ChannelStateImpl( localRead: ChannelUserRead, serverRead: ChannelUserRead, ): ChannelUserRead { + if (isReadTrackedLocally) { + // The unread count is tracked locally for this channel (read events are disabled server-side), + // so the server data must never overwrite the locally tracked read state. Note that a recency + // check is not enough: server reads carry lastReceivedEventDate = last_message_at, which ties + // with the local value anchored to the same message. Only the user info and the delivered + // fields are merged from the server. + logger.d { + "[updateReads] Read state is tracked locally, preserving: " + + "local.lastRead=${localRead.lastRead}, " + + "local.unreadMessages=${localRead.unreadMessages}, " + + "server.unreadMessages=${serverRead.unreadMessages}" + } + return localRead.copy( + user = serverRead.user, + lastDeliveredAt = serverRead.lastDeliveredAt ?: localRead.lastDeliveredAt, + lastDeliveredMessageId = serverRead.lastDeliveredMessageId ?: localRead.lastDeliveredMessageId, + ) + } return if (localRead.lastReceivedEventDate.after(serverRead.lastReceivedEventDate)) { // Local state is more recent, preserve it but merge other fields from server logger.d { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt index d12befe7fd5..dcea096a2a6 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt @@ -921,6 +921,118 @@ internal class ChannelStateLogicTest { ) } + @Test + fun `Given locally tracked channel, When updateDataForChannel is called with more recent server read, Then local read state is preserved`() { + // given - local unread count enabled, read events disabled, locally incremented count + whenever(mutableState.isLocalUnreadCountEnabled) doReturn true + _channelConfig.value = Config(readEventsEnabled = false) + + val localRead = ChannelUserRead( + user = user, + lastReceivedEventDate = Date(50L), + unreadMessages = 3, + lastRead = Date(40L), + lastReadMessageId = null, + lastDeliveredAt = null, + lastDeliveredMessageId = null, + ) + _read.value = localRead + + // Server sends a sync payload with unreadMessages = 0 and a newer event date + val serverRead = ChannelUserRead( + user = user.copy(name = "Updated Name"), + lastReceivedEventDate = Date(100L), + unreadMessages = 0, + lastRead = Date(95L), + lastReadMessageId = "server-read-id", + lastDeliveredAt = Date(85L), + lastDeliveredMessageId = "server-delivered-id", + ) + val channel = randomChannel(read = listOf(serverRead)) + + // when + channelStateLogic.updateDataForChannel( + channel = channel, + messageLimit = 0, + ) + + // then - the locally tracked read state is preserved; only user info and delivered fields + // are merged from the server + verify(mutableState).upsertReads( + eq( + listOf( + localRead.copy( + user = serverRead.user, + lastDeliveredAt = serverRead.lastDeliveredAt, + lastDeliveredMessageId = serverRead.lastDeliveredMessageId, + ), + ), + ), + ) + } + + @Test + fun `Given locally tracked channel, When updateDataForChannel is called with a server read tying on event date, Then local read state is preserved`() { + // Server reads carry lastReceivedEventDate = last_message_at, which ties with the local value + // anchored to the same message - a recency merge would let the stale server count win here. + whenever(mutableState.isLocalUnreadCountEnabled) doReturn true + _channelConfig.value = Config(readEventsEnabled = false) + + val lastMessageDate = Date(100L) + val localRead = ChannelUserRead( + user = user, + lastReceivedEventDate = lastMessageDate, + unreadMessages = 3, + lastRead = Date(40L), + lastReadMessageId = null, + ) + _read.value = localRead + + val serverRead = ChannelUserRead( + user = user, + lastReceivedEventDate = lastMessageDate, + unreadMessages = 0, + lastRead = Date(40L), + lastReadMessageId = null, + ) + val channel = randomChannel(read = listOf(serverRead)) + + // when + channelStateLogic.updateDataForChannel( + channel = channel, + messageLimit = 0, + ) + + // then + verify(mutableState).upsertReads(eq(listOf(localRead))) + } + + @Test + fun `Given locally tracked channel without local read, When updateDataForChannel is called, Then server data is used`() { + // given - first channel load, no local read state yet + whenever(mutableState.isLocalUnreadCountEnabled) doReturn true + _channelConfig.value = Config(readEventsEnabled = false) + _read.value = null + + val serverRead = ChannelUserRead( + user = user, + lastReceivedEventDate = Date(100L), + unreadMessages = 5, + lastRead = Date(95L), + lastReadMessageId = "server-read-id", + ) + val channel = randomChannel(read = listOf(serverRead)) + + // when + channelStateLogic.updateDataForChannel( + channel = channel, + messageLimit = 0, + ) + + // then - the server value is authoritative on first load + verify(mutableState).upsertReads(eq(listOf(serverRead))) + } + @Test fun `Given server read state is more recent than local, When updateDataForChannel is called, Then server data is used`() { // given - server state has more recent lastReceivedEventDate diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt index ff9eb2d5951..2d7b1c139de 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt @@ -131,6 +131,161 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() assertEquals(1, state.unreadCount.value) } + @Test + fun `updateCurrentUserRead does not create a read state when read events are enabled`() = runTest { + // read events enabled server-side, so the server owns the read state + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = true)) + val message = createMessage(1, user = randomUser(id = "other_user")) + // when + state.updateCurrentUserRead(Date(2000), message) + // then + assertNull(state.read.value) + assertEquals(0, state.unreadCount.value) + } + + @Test + fun `updateReads preserves the locally tracked read even when the server data is more recent`() = runTest { + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + state.updateRead( + createRead(currentUser, unreadMessages = 3, lastRead = Date(1000)), + ) + // when: the server sends a sync payload with unreadMessages = 0 and a newer event date + val serverRead = createRead( + user = currentUser, + unreadMessages = 0, + lastRead = Date(5000), + lastReceivedEventDate = Date(5000), + lastReadMessageId = "server_message_id", + ) + state.updateReads(listOf(serverRead)) + // then: the locally tracked read state must be preserved + val read = state.read.value + assertEquals(3, read?.unreadMessages) + assertEquals(Date(1000), read?.lastRead) + assertNull(read?.lastReadMessageId) + } + + @Test + fun `updateReads preserves the locally tracked read on equal event dates`() = runTest { + // Server reads carry lastReceivedEventDate = last_message_at, which ties with the local value + // anchored to the same message - a recency merge would let the stale server count win here. + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + val lastMessageDate = Date(3000) + state.updateRead( + createRead( + user = currentUser, + unreadMessages = 3, + lastRead = Date(1000), + lastReceivedEventDate = lastMessageDate, + ), + ) + // when: the server read ties on lastReceivedEventDate and carries a stale count + val serverRead = createRead( + user = currentUser, + unreadMessages = 0, + lastRead = Date(1000), + lastReceivedEventDate = lastMessageDate, + ) + state.updateReads(listOf(serverRead)) + // then + assertEquals(3, state.read.value?.unreadMessages) + } + + @Test + fun `updateReads merges delivered fields from the server for locally tracked reads`() = runTest { + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + state.updateRead( + createRead(currentUser, unreadMessages = 3, lastRead = Date(1000)), + ) + // when + val serverRead = createRead( + user = currentUser.copy(name = "Updated Name"), + unreadMessages = 0, + lastRead = Date(5000), + lastDeliveredAt = Date(4000), + lastDeliveredMessageId = "delivered_message_id", + ) + state.updateReads(listOf(serverRead)) + // then: user info and delivered fields come from the server, the rest stays local + val read = state.read.value + assertEquals(3, read?.unreadMessages) + assertEquals("Updated Name", read?.user?.name) + assertEquals(Date(4000), read?.lastDeliveredAt) + assertEquals("delivered_message_id", read?.lastDeliveredMessageId) + } + + @Test + fun `updateReads uses the server data when no local read exists for a locally tracked channel`() = runTest { + // first channel load, no local read state yet + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + assertNull(state.read.value) + // when + state.updateReads( + listOf(createRead(currentUser, unreadMessages = 5, lastRead = Date(1000))), + ) + // then: the server value is authoritative on first load + assertEquals(5, state.read.value?.unreadMessages) + } + + @Test + fun `updateReads uses more recent server data when read events are enabled`() = runTest { + // even with the flag on, channels with read events enabled follow the server + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = true)) + state.updateRead( + createRead(currentUser, unreadMessages = 9, lastRead = Date(1000)), + ) + // when + val serverRead = createRead( + user = currentUser, + unreadMessages = 1, + lastRead = Date(5000), + lastReceivedEventDate = Date(5000), + ) + state.updateReads(listOf(serverRead)) + // then + assertEquals(1, state.read.value?.unreadMessages) + } + + @Test + fun `updateReads uses more recent server data when local tracking is disabled`() = runTest { + // flag off, the standard recency merge applies + val state = localTrackingState(isLocalUnreadCountEnabled = false) + state.setChannelConfig(Config(readEventsEnabled = false)) + state.updateRead( + createRead(currentUser, unreadMessages = 7, lastRead = Date(1000)), + ) + // when + val serverRead = createRead( + user = currentUser, + unreadMessages = 0, + lastRead = Date(5000), + lastReceivedEventDate = Date(5000), + ) + state.updateReads(listOf(serverRead)) + // then + assertEquals(0, state.read.value?.unreadMessages) + } + + @Test + fun `updateReads does not affect other users reads for locally tracked channels`() = runTest { + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + val otherUser = randomUser(id = "other_user") + // when + state.updateReads( + listOf(createRead(otherUser, unreadMessages = 10, lastRead = Date(2000))), + ) + // then + val otherRead = state.reads.value.find { it.user.id == "other_user" } + assertEquals(10, otherRead?.unreadMessages) + } + private fun localTrackingState(isLocalUnreadCountEnabled: Boolean = true) = ChannelStateImpl( channelType = CHANNEL_TYPE, channelId = CHANNEL_ID, @@ -148,11 +303,15 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() lastRead: Date, lastReceivedEventDate: Date = lastRead, lastReadMessageId: String? = null, + lastDeliveredAt: Date? = null, + lastDeliveredMessageId: String? = null, ): ChannelUserRead = ChannelUserRead( user = user, lastReceivedEventDate = lastReceivedEventDate, unreadMessages = unreadMessages, lastRead = lastRead, lastReadMessageId = lastReadMessageId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, ) } From 3fcf19809dbb94151fd969d8e5e2ad4999617240 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:16:52 +0000 Subject: [PATCH 04/21] compose-sample: Add local unread count feature flag --- .../chat/android/compose/sample/ChatHelper.kt | 6 +++++- .../android/compose/sample/data/CustomSettings.kt | 2 ++ .../compose/sample/ui/login/CustomLoginActivity.kt | 12 ++++++++++++ .../src/main/res/values/strings.xml | 2 ++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt index 0f8dc8118bd..8cf16581215 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt @@ -25,6 +25,7 @@ import io.getstream.chat.android.client.logger.ChatLogLevel import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.notifications.handler.NotificationHandlerFactory import io.getstream.chat.android.compose.sample.data.UserCredentials +import io.getstream.chat.android.compose.sample.data.customSettings import io.getstream.chat.android.compose.sample.ui.StartupActivity import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.EventType @@ -83,7 +84,10 @@ object ChatHelper { }, ) - val chatClientConfig = ChatClientConfig(userPresence = true) + val chatClientConfig = ChatClientConfig( + userPresence = true, + isLocalUnreadCountEnabled = context.customSettings().isLocalUnreadCountEnabled, + ) val logLevel = if (BuildConfig.DEBUG) ChatLogLevel.ALL else ChatLogLevel.NOTHING diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt index f238e9443dd..c27561ebfc6 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/data/CustomSettings.kt @@ -38,6 +38,7 @@ class CustomSettings(private val context: Context) { var isComposerFloatingStyleEnabled: Boolean by booleanPref(ComposerFloatingStyle) var isSystemAttachmentPickerEnabled: Boolean by booleanPref(SystemAttachmentPicker) var isLocationSharingEnabled: Boolean by booleanPref(LocationSharing) + var isLocalUnreadCountEnabled: Boolean by booleanPref(LocalUnreadCount) private fun booleanPref(key: String, default: Boolean = false) = object : ReadWriteProperty { @@ -55,5 +56,6 @@ private const val ComposerLinkPreview = "composer_link_preview" private const val ComposerFloatingStyle = "composer_floating_style" private const val SystemAttachmentPicker = "system_attachment_picker" private const val LocationSharing = "location_sharing" +private const val LocalUnreadCount = "local_unread_count" fun Context.customSettings(): CustomSettings = CustomSettings(this) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt index 19f3048cd44..8637fe73634 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/CustomLoginActivity.kt @@ -141,6 +141,9 @@ class CustomLoginActivity : AppCompatActivity() { var isLocationSharingEnabled by remember { mutableStateOf(settings.isLocationSharingEnabled) } + var isLocalUnreadCountEnabled by remember { + mutableStateOf(settings.isLocalUnreadCountEnabled) + } val isLoginButtonEnabled = apiKeyText.isNotEmpty() && userIdText.isNotEmpty() && @@ -207,6 +210,15 @@ class CustomLoginActivity : AppCompatActivity() { settings.isLocationSharingEnabled = it }, ), + FeatureFlag( + label = stringResource(R.string.custom_login_flag_local_unread_count_label), + description = stringResource(R.string.custom_login_flag_local_unread_count_description), + value = isLocalUnreadCountEnabled, + onValueChange = { + isLocalUnreadCountEnabled = it + settings.isLocalUnreadCountEnabled = it + }, + ), ) CustomLoginInputField( diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index 0ae6fe91339..2feae65a6ff 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -45,6 +45,8 @@ Add a Location option to the attachment picker for sharing static and live locations Channel pinning Show the Pin/Unpin Chat action in the channel options menu + Local unread count + Track the unread count locally for channels with read events disabled (e.g. livestreams) Pinned Messages From 17a8dfa4b712cd1e3b71ba334411ce8fe9207572 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:18:29 +0000 Subject: [PATCH 05/21] client: Route locally tracked read persistence around the repository server-data merge --- .../internal/DatabaseChannelRepository.kt | 43 ++++- .../internal/DatabaseRepositoryFactory.kt | 1 + .../internal/EventHandlerSequential.kt | 46 ++++-- .../factory/StreamStatePluginFactory.kt | 3 + .../channel/internal/ChannelLogicImpl.kt | 10 +- .../internal/legacy/ChannelLogicLegacyImpl.kt | 10 +- .../repository/ChannelRepository.kt | 21 +++ .../internal/ChannelRepositoryImplTest.kt | 152 ++++++++++++++++++ .../state/event/TotalUnreadCountTest.kt | 1 + .../internal/EventHandlerSequentialTest.kt | 78 +++++++++ ...andlerSequentialUserMessagesDeletedTest.kt | 1 + .../channel/internal/ChannelLogicImplTest.kt | 16 +- 12 files changed, 346 insertions(+), 36 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt index 99818417a06..4b6ff67e93a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt @@ -47,6 +47,7 @@ internal class DatabaseChannelRepository( private val getUser: suspend (userId: String) -> User, private val getMessage: suspend (messageId: String) -> Message?, private val getDraftMessage: suspend (cid: String) -> DraftMessage?, + private val currentUserId: String, private val now: () -> Long = { System.currentTimeMillis() }, cacheSize: Int = 1000, ) : ChannelRepository { @@ -86,9 +87,13 @@ internal class DatabaseChannelRepository( /** * For channels with server-side read events disabled the per-channel unread count is tracked - * on-device, so a stored read with a newer [ChannelUserRead.lastReceivedEventDate] must not be - * overwritten by a stale server payload. Mirrors the in-memory channel-state merge, but also - * survives a cache miss (e.g. after a process restart) by falling back to the persisted row. + * on-device, so a server payload must never overwrite the stored current user read. Note that + * a recency check is not enough: server reads carry lastReceivedEventDate = last_message_at, + * which ties with the locally tracked value anchored to the same message. The locally tracked + * reads reach the database through [upsertChannelReads], which bypasses this merge, so every + * [insertChannels] write for these channels' reads is server-sourced by construction. For + * other users' reads a recency check is applied. Falls back to the persisted row on a cache + * miss (e.g. after a process restart). */ private suspend fun Channel.preserveLocallyTrackedReads(): Channel { val storedReads = channelCache[cid]?.read @@ -98,13 +103,41 @@ internal class DatabaseChannelRepository( val mergedByUser = read.associateBy(ChannelUserRead::getUserId).toMutableMap() storedReads.forEach { stored -> val incoming = mergedByUser[stored.getUserId()] - if (incoming == null || stored.lastReceivedEventDate.after(incoming.lastReceivedEventDate)) { - mergedByUser[stored.getUserId()] = stored + mergedByUser[stored.getUserId()] = when { + incoming == null -> stored + stored.getUserId() == currentUserId -> stored.copy( + // Only the user info and the delivered fields are merged from the server, + // mirroring the in-memory merge in the channel state. + user = incoming.user, + lastDeliveredAt = incoming.lastDeliveredAt ?: stored.lastDeliveredAt, + lastDeliveredMessageId = incoming.lastDeliveredMessageId ?: stored.lastDeliveredMessageId, + ) + stored.lastReceivedEventDate.after(incoming.lastReceivedEventDate) -> stored + else -> incoming } } return copy(read = mergedByUser.values.toList()).syncUnreadCountWithReads() } + /** + * Upserts the given [reads] into the stored channel, replacing the stored reads of the same + * users and keeping the rest. The write is applied verbatim (no merge against the stored read + * state): the caller is the source of truth for these reads. Ordering with concurrent + * [insertChannels] writes for the same channel is guaranteed by the shared [dbMutex]. + */ + override suspend fun upsertChannelReads(cid: String, reads: List) { + if (reads.isEmpty()) return + val stored = selectChannel(cid) ?: return + val updatedChannel = stored + .copy(read = (reads + stored.read).distinctBy(ChannelUserRead::getUserId)) + .syncUnreadCountWithReads() + cacheChannel(updatedChannel) + scope.launchWithMutex(dbMutex) { + logger.v { "[upsertChannelReads] cid: $cid, reads.size: ${reads.size}" } + channelDao.insert(updatedChannel.toEntity()) + } + } + private fun cacheChannel(vararg channels: Channel) { channels.forEach { channelCache.put(it.cid, it) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/factory/internal/DatabaseRepositoryFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/factory/internal/DatabaseRepositoryFactory.kt index 05fea7c78ab..d409354f368 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/factory/internal/DatabaseRepositoryFactory.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/factory/internal/DatabaseRepositoryFactory.kt @@ -86,6 +86,7 @@ internal class DatabaseRepositoryFactory( getUser, getMessage, messageRepository::selectDraftMessagesByCid, + currentUser.id, now, ) .also { repository -> diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index c43844ef05f..c915343fa0e 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -103,7 +103,6 @@ import io.getstream.chat.android.client.extensions.internal.updateMembership import io.getstream.chat.android.client.extensions.internal.updateMembershipBanned import io.getstream.chat.android.client.extensions.internal.updateParentOrReply import io.getstream.chat.android.client.extensions.internal.updateReads -import io.getstream.chat.android.client.extensions.syncUnreadCountWithReads import io.getstream.chat.android.client.internal.state.event.handler.grouped.internal.GroupedUnreadChannelsUpdater import io.getstream.chat.android.client.internal.state.event.handler.internal.batch.BatchEvent import io.getstream.chat.android.client.internal.state.event.handler.internal.batch.SocketEventCollector @@ -120,9 +119,7 @@ import io.getstream.chat.android.client.utils.internal.ChannelId 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.ChannelUserRead import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageReminder @@ -169,6 +166,7 @@ internal class EventHandlerSequential( private val sideEffect: suspend () -> Unit, private val syncedEvents: Flow>, private val bufferConfig: MessageBufferConfig, + private val isLocalUnreadCountEnabled: Boolean, scope: CoroutineScope, private val groupedUnreadChannelsUpdater: GroupedUnreadChannelsUpdater = GroupedUnreadChannelsUpdater( stateRegistry = stateRegistry, @@ -645,7 +643,7 @@ internal class EventHandlerSequential( messages = channel.messages + listOf(enrichedMessage), messageCount = event.channelMessageCount ?: channel.messageCount, lastMessageAt = newLastMessageAt, - ).withLocallyTrackedReads(event.cid) + ) batch.addChannel(updatedChannel) // Update thread data in DB if the new message is added to a thread batch.addThreadIfExists(enrichedMessage) @@ -895,6 +893,10 @@ internal class EventHandlerSequential( // execute the batch batch.execute() + // persist the locally tracked reads for the affected channels, after the batch has stored + // the server data + persistLocallyTrackedReads(events) + // handle delete and truncate events for (event in events) { when (event) { @@ -977,17 +979,33 @@ internal class EventHandlerSequential( /** * For channels with server-side read events disabled, the per-channel unread count is tracked - * on-device in the channel state. This carries the current in-memory reads into the channel - * being persisted so the count survives a restart. No-op for regular channels and for channels - * that are not currently active in state. + * on-device in the channel state. This persists the current in-memory reads of the channels + * affected by new-message events, so the count survives a restart. The write goes through + * [RepositoryFacade.upsertChannelReads], which bypasses the repository's server-data merge; + * it runs after the batch has stored the server data, and the two writes are serialized by + * the repository's internal mutex. No-op when local unread count tracking is disabled, and + * for regular channels or channels that are not currently active in state. */ - private fun Channel.withLocallyTrackedReads(cid: String): Channel { - if (config.readEventsEnabled) return this - val channelId = ChannelId.fromCid(cid) ?: return this - if (!stateRegistry.isActiveChannel(channelId)) return this - val stateReads = stateRegistry.channel(channelId).reads.value - if (stateReads.isEmpty()) return this - return copy(read = (stateReads + read).distinctBy(ChannelUserRead::getUserId)).syncUnreadCountWithReads() + private suspend fun persistLocallyTrackedReads(events: List) { + if (!isLocalUnreadCountEnabled) return + events + .mapNotNull { event -> + when (event) { + is NewMessageEvent -> event.cid + is NotificationMessageNewEvent -> event.cid + else -> null + } + } + .distinct() + .forEach { cid -> + val channelId = ChannelId.fromCid(cid) ?: return@forEach + if (!stateRegistry.isActiveChannel(channelId)) return@forEach + val channelState = stateRegistry.channel(channelId) + if (channelState.channelConfig.value.readEventsEnabled) return@forEach + val stateReads = channelState.reads.value + if (stateReads.isEmpty()) return@forEach + repos.upsertChannelReads(cid = cid, reads = stateReads) + } } private suspend fun deleteMessagesFromUser(cid: String?, userId: String, hard: Boolean, deletedAt: Date) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt index 1931a6dee78..5d3b313af46 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/factory/StreamStatePluginFactory.kt @@ -164,6 +164,7 @@ public class StreamStatePluginFactory( syncedEvents = syncManager.syncedEvents, sideEffect = syncManager::awaitSyncing, bufferConfig = config.messageLimitConfig.messageBufferConfig, + isLocalUnreadCountEnabled = config.isLocalUnreadCountEnabled, ) val stateErrorHandlerFactory = StateErrorHandlerFactory( @@ -202,6 +203,7 @@ public class StreamStatePluginFactory( sideEffect: suspend () -> Unit, syncedEvents: Flow>, bufferConfig: MessageBufferConfig, + isLocalUnreadCountEnabled: Boolean, ): EventHandler { return EventHandlerSequential( scope = scope, @@ -216,6 +218,7 @@ public class StreamStatePluginFactory( syncedEvents = syncedEvents, sideEffect = sideEffect, bufferConfig = bufferConfig, + isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, ) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt index c5fbc69e255..9955f37db91 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt @@ -34,7 +34,6 @@ import io.getstream.chat.android.client.internal.state.plugin.state.channel.inte import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PendingMessage @@ -259,17 +258,14 @@ internal class ChannelLogicImpl( /** * Persists the current in-memory reads to the database. Used by the on-device unread tracking - * path, where the read state is not backed by a server response. The in-memory reads are merged - * over the stored ones so a partially populated state never drops reads already persisted. + * path, where the read state is not backed by a server response. Writes through + * [ChannelRepository.upsertChannelReads], which bypasses the repository's server-data merge. */ private fun persistCurrentReads() { val reads = state.reads.value if (reads.isEmpty()) return coroutineScope.launch { - repository.selectChannel(state.cid)?.let { channel -> - val mergedReads = (reads + channel.read).distinctBy(ChannelUserRead::getUserId) - repository.insertChannel(channel.copy(read = mergedReads)) - } + repository.upsertChannelReads(cid = state.cid, reads = reads) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt index 435cccbf269..1c75196ddc1 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt @@ -34,7 +34,6 @@ import io.getstream.chat.android.client.internal.state.plugin.state.channel.inte import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PushPreference @@ -205,15 +204,12 @@ internal class ChannelLogicLegacyImpl( val result = stateLogic.markRead() if (result == MarkReadResult.HandledLocally) { // Local mark-read only mutates in-memory state; persist the reset read so the on-device - // unread count survives a restart. The in-memory reads are merged over the stored ones - // so a partially populated state never drops reads already persisted. + // unread count survives a restart. The write goes through upsertChannelReads, which + // bypasses the repository's server-data merge. val reads = mutableState.reads.value if (reads.isNotEmpty()) { coroutineScope.launch { - repos.selectChannel(mutableState.cid)?.let { channel -> - val mergedReads = (reads + channel.read).distinctBy(ChannelUserRead::getUserId) - repos.insertChannel(channel.copy(read = mergedReads)) - } + repos.upsertChannelReads(cid = mutableState.cid, reads = reads) } } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt index 425a539eceb..67c3e958d5b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.client.persistance.repository import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Member import io.getstream.chat.android.models.Message import java.util.Date @@ -155,6 +156,26 @@ public interface ChannelRepository { */ public suspend fun updateLastMessageForChannel(cid: String, lastMessage: Message) + /** + * Upserts the given [reads] into the stored channel, replacing the stored reads of the same + * users and keeping the rest. + * + * Used by the local unread count tracking (`ChatClientConfig.isLocalUnreadCountEnabled`) to + * persist reads that are not backed by a server response, for channels with server-side read + * events disabled. Unlike [insertChannel], the write is applied verbatim - it is not merged + * against the stored read state, because the caller (the in-memory channel state) is the + * source of truth for these reads. + * + * The default implementation is a no-op: custom [ChannelRepository] implementations must + * override it for the locally tracked unread count to survive across app restarts. + * + * @param cid The full channel id of the channel to update, ie messaging:123. + * @param reads The reads to upsert. + */ + public suspend fun upsertChannelReads(cid: String, reads: List) { + // no-op by default + } + /** * Evict a [Channel] from the repository. * diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt index 4cd9e4176dd..390b39aaf26 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt @@ -58,6 +58,7 @@ internal class ChannelRepositoryImplTest { { randomUser() }, { randomMessage() }, { randomDraftMessageOrNull() }, + "current-user", ) @BeforeEach @@ -137,6 +138,156 @@ internal class ChannelRepositoryImplTest { assertEquals(0, persistedUnreadCount(cid)) } + @Test + fun `insertChannels preserves the stored current user read on tying event dates for a read-events-disabled channel`() = + runTest { + val repo = repositoryWithUserEchoingIds() + val cid = "messaging:local" + // Server reads carry lastReceivedEventDate = last_message_at, which ties with the locally + // tracked value anchored to the same message - a recency merge would let the stale server + // count win here. + val lastMessageDate = Date(2000) + val stored = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead( + user = currentUser, + unreadMessages = 5, + lastReceivedEventDate = lastMessageDate, + ), + ), + ) + whenever(channelDao.select(cid)) doReturn stored.toEntity() + val serverPayload = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead( + user = currentUser, + unreadMessages = 0, + lastReceivedEventDate = lastMessageDate, + ), + ), + ) + + repo.insertChannels(listOf(serverPayload)) + + assertEquals(5, persistedUnreadCount(cid)) + } + + @Test + fun `insertChannels preserves the stored current user read even when the server read is more recent`() = runTest { + val repo = repositoryWithUserEchoingIds() + val cid = "messaging:local" + val stored = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead(user = currentUser, unreadMessages = 5, lastReceivedEventDate = Date(1000)), + ), + ) + whenever(channelDao.select(cid)) doReturn stored.toEntity() + val serverPayload = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead(user = currentUser, unreadMessages = 0, lastReceivedEventDate = Date(2000)), + ), + ) + + repo.insertChannels(listOf(serverPayload)) + + assertEquals(5, persistedUnreadCount(cid)) + } + + @Test + fun `insertChannels lets a newer server read win for other users on a read-events-disabled channel`() = runTest { + val repo = repositoryWithUserEchoingIds() + val cid = "messaging:local" + val otherUser = randomUser(id = "other-user") + val stored = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead(user = otherUser, unreadMessages = 5, lastReceivedEventDate = Date(1000)), + ), + ) + whenever(channelDao.select(cid)) doReturn stored.toEntity() + val serverPayload = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead(user = otherUser, unreadMessages = 0, lastReceivedEventDate = Date(2000)), + ), + ) + + repo.insertChannels(listOf(serverPayload)) + + val captor = argumentCaptor>() + verify(channelDao).insertMany(captor.capture()) + val persisted = captor.allValues.flatten().firstOrNull { it.cid == cid } + assertEquals(0, persisted?.reads?.get(otherUser.id)?.unreadMessages) + } + + @Test + fun `upsertChannelReads replaces the stored current user read and keeps other users reads`() = runTest { + val repo = repositoryWithUserEchoingIds() + val cid = "messaging:local" + val otherUser = randomUser(id = "other-user") + val stored = randomChannel( + id = "local", + type = "messaging", + config = randomConfig(readEventsEnabled = false), + read = listOf( + randomChannelUserRead(user = currentUser, unreadMessages = 5, lastReceivedEventDate = Date(2000)), + randomChannelUserRead(user = otherUser, unreadMessages = 7, lastReceivedEventDate = Date(2000)), + ), + ) + whenever(channelDao.select(cid)) doReturn stored.toEntity() + + // The local mark-read resets the count; the write is applied verbatim, no recency merge. + val resetRead = randomChannelUserRead( + user = currentUser, + unreadMessages = 0, + lastReceivedEventDate = Date(2000), + ) + repo.upsertChannelReads(cid, listOf(resetRead)) + + val captor = argumentCaptor() + verify(channelDao).insert(captor.capture()) + val persisted = captor.firstValue + assertEquals(0, persisted.reads[currentUser.id]?.unreadMessages) + assertEquals(7, persisted.reads[otherUser.id]?.unreadMessages) + } + + @Test + fun `upsertChannelReads does nothing when the channel is not stored`() = runTest { + val repo = repositoryWithUserEchoingIds() + val cid = "messaging:missing" + whenever(channelDao.select(cid)) doReturn null + + repo.upsertChannelReads(cid, listOf(randomChannelUserRead(user = currentUser))) + + verify(channelDao, never()).insert(any()) + } + + @Test + fun `upsertChannelReads does nothing when the reads are empty`() = runTest { + val repo = repositoryWithUserEchoingIds() + + repo.upsertChannelReads("messaging:local", emptyList()) + + verify(channelDao, never()).select(any()) + verify(channelDao, never()).insert(any()) + } + private val currentUser = randomUser(id = "current-user") private fun repositoryWithUserEchoingIds(): DatabaseChannelRepository = @@ -146,6 +297,7 @@ internal class ChannelRepositoryImplTest { { userId -> randomUser(id = userId) }, { randomMessage() }, { randomDraftMessageOrNull() }, + currentUser.id, ) /** Returns the current user's unread count from the entity written to the DAO for [cid]. */ diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/TotalUnreadCountTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/TotalUnreadCountTest.kt index 395046f8430..18a7f4d3550 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/TotalUnreadCountTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/TotalUnreadCountTest.kt @@ -146,6 +146,7 @@ internal class TotalUnreadCountTest { sideEffect = sideEffect, syncedEvents = syncedEvents, bufferConfig = MessageBufferConfig(), + isLocalUnreadCountEnabled = false, ) fun givenMockedRepositories(): Fixture { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt index 7b4a883481d..5f23dd62d8f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -84,6 +84,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.atLeast import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.stub import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -420,6 +421,56 @@ internal class EventHandlerSequentialTest { } } + @Test + fun `When local unread tracking is enabled, a new message event persists the locally tracked reads`() = runTest { + val channelType = "livestream" + val channelId = "local-unread" + val cid = "$channelType:$channelId" + val repos: RepositoryFacade = mock() + repos.stub { + onBlocking { selectChannel(cid) } doReturn + randomChannel(id = channelId, type = channelType, ownCapabilities = emptySet()) + onBlocking { selectChannels(any()) } doReturn emptyList() + onBlocking { selectMessages(any()) } doReturn emptyList() + onBlocking { selectThreads(any()) } doReturn emptyList() + } + val localReads = listOf( + io.getstream.chat.android.randomChannelUserRead(user = currentUser, unreadMessages = 3), + ) + val handler = Fixture() + .withRepositoryFacade(repos) + .withLocalUnreadCountEnabled() + .withLocallyTrackedChannel(channelType, channelId, localReads) + .get(this) + + handler.handleEvents(randomNewMessageEvent(cid = cid, channelType = channelType, channelId = channelId)) + + verify(repos).upsertChannelReads(cid, localReads) + } + + @Test + fun `When local unread tracking is disabled, a new message event does not persist reads`() = runTest { + val channelType = "livestream" + val channelId = "local-unread" + val cid = "$channelType:$channelId" + val repos: RepositoryFacade = mock() + repos.stub { + onBlocking { selectChannel(cid) } doReturn + randomChannel(id = channelId, type = channelType, ownCapabilities = emptySet()) + } + val localReads = listOf( + io.getstream.chat.android.randomChannelUserRead(user = currentUser, unreadMessages = 3), + ) + val handler = Fixture() + .withRepositoryFacade(repos) + .withLocallyTrackedChannel(channelType, channelId, localReads) + .get(this) + + handler.handleEvents(randomNewMessageEvent(cid = cid, channelType = channelType, channelId = channelId)) + + verify(repos, never()).upsertChannelReads(any(), any()) + } + @Test fun `When buffer overflows with DROP_OLDEST, the oldest queued NewMessageEvent is dropped`() = runTest { val fixture = Fixture() @@ -602,6 +653,11 @@ internal class EventHandlerSequentialTest { private val sideEffect: suspend () -> Unit = { sideEffectGate.await() } private val syncedEvents: Flow> = emptyFlow() private var bufferConfig: MessageBufferConfig = MessageBufferConfig() + private var isLocalUnreadCountEnabled: Boolean = false + + fun withLocalUnreadCountEnabled() = apply { + isLocalUnreadCountEnabled = true + } fun withReadEventsCapability(cid: String) = apply { repos.stub { @@ -677,6 +733,27 @@ internal class EventHandlerSequentialTest { whenever(stateRegistry.channel(channelType, channelId)) doReturn channelMutableState } + /** + * Stubs [stateRegistry] so the channel identified by [channelType] / [channelId] is + * active, has read events disabled, and carries the given locally tracked [reads]. + */ + fun withLocallyTrackedChannel( + channelType: String, + channelId: String, + reads: List, + ) = apply { + val channelState: io.getstream.chat.android.client.channel.state.ChannelState = + mock { + on { it.channelConfig } doReturn + MutableStateFlow(io.getstream.chat.android.models.Config(readEventsEnabled = false)) + on { it.reads } doReturn MutableStateFlow(reads) + } + val cid = io.getstream.chat.android.client.utils.internal.ChannelId + .fromTypeAndId(channelType, channelId)!! + whenever(stateRegistry.isActiveChannel(cid)) doReturn true + whenever(stateRegistry.channel(cid)) doReturn channelState + } + fun get(scope: CoroutineScope) = EventHandlerSequential( currentUserId = currentUser.id, subscribeForEvents = subscribeForEvents, @@ -688,6 +765,7 @@ internal class EventHandlerSequentialTest { sideEffect = sideEffect, syncedEvents = syncedEvents, bufferConfig = bufferConfig, + isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, scope = scope, ) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialUserMessagesDeletedTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialUserMessagesDeletedTest.kt index 5ab23f068a3..9e19d652d7f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialUserMessagesDeletedTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialUserMessagesDeletedTest.kt @@ -347,6 +347,7 @@ internal class EventHandlerSequentialUserMessagesDeletedTest { sideEffect = sideEffect, syncedEvents = syncedEvents, bufferConfig = MessageBufferConfig(), + isLocalUnreadCountEnabled = false, scope = scope, ) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt index 6764437f700..25aa2e532ca 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt @@ -913,11 +913,10 @@ internal class ChannelLogicImplTest { whenever(stateImpl.markRead()).thenReturn(MarkReadResult.HandledLocally) whenever(stateImpl.cid).thenReturn(cid) whenever(stateImpl.reads).thenReturn(MutableStateFlow(reads)) - whenever(repository.selectChannel(cid)).thenReturn(randomChannel()) // When sut.markRead() // Then - verify(repository).insertChannel(any()) + verify(repository).upsertChannelReads(cid, reads) } @Test @@ -927,7 +926,18 @@ internal class ChannelLogicImplTest { // When sut.markRead() // Then - verify(repository, never()).insertChannel(any()) + verify(repository, never()).upsertChannelReads(any(), any()) + } + + @Test + fun `markRead does not persist when the state has no reads`() = runTest { + // Given + whenever(stateImpl.markRead()).thenReturn(MarkReadResult.HandledLocally) + whenever(stateImpl.reads).thenReturn(MutableStateFlow(emptyList())) + // When + sut.markRead() + // Then + verify(repository, never()).upsertChannelReads(any(), any()) } // endregion From e9a8416fb22fd1387057c0c24ef7b23dbf52e120 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:58:35 +0000 Subject: [PATCH 06/21] client: Refresh channel list unread count after local mark-read --- .../internal/ChannelMarkReadListenerState.kt | 7 ++- .../ChannelMarkReadListenerStateTest.kt | 52 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt index e7caf93d860..c2aaf1d5f9d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt @@ -44,8 +44,13 @@ internal class ChannelMarkReadListenerState(private val logic: LogicRegistry) : override suspend fun onChannelMarkReadPrecondition(channelType: String, channelId: String): Result { return when (logic.channel(channelType, channelId).markRead()) { MarkReadResult.RemoteRequired -> Result.Success(Unit) - MarkReadResult.HandledLocally -> + MarkReadResult.HandledLocally -> { + // No server read event will follow a local mark-read, so refresh the channel in the + // active channel-list queries for the reset unread count to be reflected there. + val cid = "$channelType:$channelId" + logic.getActiveQueryChannelsLogic().forEach { it.refreshChannelState(cid) } Result.Failure(Error.GenericError("Channel $channelId marked as read locally")) + } MarkReadResult.NotNeeded -> Result.Failure(Error.GenericError("Can not mark channel as read with channel id: $channelId")) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt index f2d433f8de5..43b99b812f0 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt @@ -18,13 +18,17 @@ package io.getstream.chat.android.client.internal.state.plugin.listener.internal import io.getstream.chat.android.client.internal.state.plugin.logic.channel.internal.ChannelLogic import io.getstream.chat.android.client.internal.state.plugin.logic.internal.LogicRegistry +import io.getstream.chat.android.client.internal.state.plugin.logic.querychannels.internal.QueryChannelsLogic import io.getstream.chat.android.client.internal.state.plugin.state.channel.internal.MarkReadResult import io.getstream.result.Result import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Test +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify internal class ChannelMarkReadListenerStateTest { @@ -49,6 +53,48 @@ internal class ChannelMarkReadListenerStateTest { assertInstanceOf(Result.Failure::class.java, result) } + @Test + fun `Local result refreshes the channel in the active channel-list queries`() = runTest { + // No server read event follows a local mark-read, so the listener must trigger the + // channel-list refresh that the event would otherwise cause. + val queryChannelsLogic: QueryChannelsLogic = mock() + val listener = listenerWith( + markReadResult = MarkReadResult.HandledLocally, + activeQueryChannelsLogic = listOf(queryChannelsLogic), + ) + // when + listener.onChannelMarkReadPrecondition(channelType, channelId) + // then + verify(queryChannelsLogic).refreshChannelState("$channelType:$channelId") + } + + @Test + fun `Remote result does not refresh the channel-list queries`() = runTest { + // The server read event triggers the refresh through the regular event handling. + val queryChannelsLogic: QueryChannelsLogic = mock() + val listener = listenerWith( + markReadResult = MarkReadResult.RemoteRequired, + activeQueryChannelsLogic = listOf(queryChannelsLogic), + ) + // when + listener.onChannelMarkReadPrecondition(channelType, channelId) + // then + verify(queryChannelsLogic, never()).refreshChannelState(any()) + } + + @Test + fun `None result does not refresh the channel-list queries`() = runTest { + val queryChannelsLogic: QueryChannelsLogic = mock() + val listener = listenerWith( + markReadResult = MarkReadResult.NotNeeded, + activeQueryChannelsLogic = listOf(queryChannelsLogic), + ) + // when + listener.onChannelMarkReadPrecondition(channelType, channelId) + // then + verify(queryChannelsLogic, never()).refreshChannelState(any()) + } + @Test fun `None result makes the precondition fail`() = runTest { val listener = listenerWith(markReadResult = MarkReadResult.NotNeeded) @@ -58,12 +104,16 @@ internal class ChannelMarkReadListenerStateTest { assertInstanceOf(Result.Failure::class.java, result) } - private fun listenerWith(markReadResult: MarkReadResult): ChannelMarkReadListenerState { + private fun listenerWith( + markReadResult: MarkReadResult, + activeQueryChannelsLogic: List = emptyList(), + ): ChannelMarkReadListenerState { val channelLogic: ChannelLogic = mock { on { it.markRead() } doReturn markReadResult } val logic: LogicRegistry = mock { on { it.channel(channelType, channelId) } doReturn channelLogic + on { it.getActiveQueryChannelsLogic() } doReturn activeQueryChannelsLogic } return ChannelMarkReadListenerState(logic) } From f0891b971ba41cef86dcafd07c395678f93225e2 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:41:00 +0000 Subject: [PATCH 07/21] client: Log unread count update skips in channel state --- .../channel/internal/ChannelStateImpl.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index 8dbcccebc84..655d25e2967 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -1071,45 +1071,57 @@ internal class ChannelStateImpl( // Skip update if the message was already processed val isProcessed = processedMessageIds[message.id] == true if (isProcessed) { + logUnreadCountSkip(message, "message already processed") return } // Skip update if the channel is muted val isMuted = muted.value if (isMuted) { + logUnreadCountSkip(message, "channel is muted") processedMessageIds.put(message.id, true) return } // Skip update for thread replies not shown in channel val isThreadReplyNotInChannel = message.parentId != null && !message.showInChannel if (isThreadReplyNotInChannel) { + logUnreadCountSkip(message, "thread reply not shown in channel") processedMessageIds.put(message.id, true) return } // Skip update for messages from current user val isFromCurrentUser = message.user.id == currentUser.value?.id if (isFromCurrentUser) { + logUnreadCountSkip(message, "own message") processedMessageIds.put(message.id, true) return } // Skip update for messages from muted users val isFromMutedUser = mutedUsers.value.any { it.target?.id == message.user.id } if (isFromMutedUser) { + logUnreadCountSkip(message, "message author is muted") processedMessageIds.put(message.id, true) return } // Skip update for messages from shadow banned users if (message.shadowed) { + logUnreadCountSkip(message, "message is shadowed") processedMessageIds.put(message.id, true) return } // Skip update for silent messages if (message.silent) { + logUnreadCountSkip(message, "message is silent") processedMessageIds.put(message.id, true) return } // Skip update if the event is outdated val currentRead = read.value if (currentRead != null && currentRead.lastReceivedEventDate.after(eventReceivedDate)) { + logUnreadCountSkip( + message, + "event is outdated, read.lastReceivedEventDate: ${currentRead.lastReceivedEventDate}, " + + "eventReceivedDate: $eventReceivedDate", + ) processedMessageIds.put(message.id, true) return } @@ -1118,6 +1130,10 @@ internal class ChannelStateImpl( processedMessageIds.put(message.id, true) } + private fun logUnreadCountSkip(message: Message, reason: String) { + logger.v { "[updateCurrentUserRead] cid: $cid, message: ${message.id} does not update unread count: $reason" } + } + /** * `true` if the per-channel unread count is tracked locally for this channel, i.e. local unread count tracking * is enabled and read events are disabled server-side. @@ -1156,6 +1172,10 @@ internal class ChannelStateImpl( null } updatedRead?.let { newRead -> + logger.v { + "[incrementUnreadCount] cid: $cid, unreadMessages: ${newRead.unreadMessages}, " + + "lastReceivedEventDate: ${newRead.lastReceivedEventDate}" + } _reads.update { current -> current + (newRead.getUserId() to newRead) } @@ -1223,6 +1243,10 @@ internal class ChannelStateImpl( lastReadMessageId = lastMessage?.id ?: currentUserRead.lastReadMessageId, unreadMessages = 0, ) + logger.v { + "[markReadLocally] cid: $cid, lastRead: ${updatedRead.lastRead}, " + + "lastReceivedEventDate: ${updatedRead.lastReceivedEventDate}" + } _reads.update { current -> current + (updatedRead.getUserId() to updatedRead) } From 14ad31ff01452371882c53235cba9d8bb4a050cd Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:54:19 +0000 Subject: [PATCH 08/21] client: Keep locally tracked unread counts in the channel list on cold start --- .../channel/internal/ChannelLogicImpl.kt | 10 +++-- .../internal/legacy/ChannelStateLogic.kt | 6 ++- .../internal/QueryChannelsStateLogic.kt | 8 ++++ .../channel/internal/ChannelLogicImplTest.kt | 24 +++++++++++ .../internal/legacy/ChannelStateLogicTest.kt | 18 +++++++++ .../internal/QueryChannelsStateLogicTest.kt | 40 +++++++++++++++++++ 6 files changed, 100 insertions(+), 6 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt index 9955f37db91..cda9eb95d6a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt @@ -136,10 +136,11 @@ internal class ChannelLogicImpl( state.setMemberCount(channel.memberCount) state.upsertMembers(channel.members) state.upsertWatchers(channel.watchers, channel.watcherCount) + // Update config. Must happen before updating the reads: the read merge checks + // the config to protect locally tracked reads from being overwritten. + state.setChannelConfig(channel.config) // Update reads state.updateReads(channel.read) - // Update config - state.setChannelConfig(channel.config) // Update messages if (limit > 0) { updateMessages(query, channel) @@ -312,10 +313,11 @@ internal class ChannelLogicImpl( state.setMemberCount(channel.memberCount) state.upsertMembers(channel.members) state.upsertWatchers(channel.watchers, channel.watcherCount) + // Update channel config. Must happen before updating the reads: the read merge checks + // the config to protect locally tracked reads from being overwritten. + state.setChannelConfig(channel.config) // Update reads state.updateReads(channel.read) - // Update channel config - state.setChannelConfig(channel.config) // Set pending messages state.setPendingMessages(channel.pendingMessages.map(PendingMessage::message)) // Update messages based on the relationship between the incoming page and existing state. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt index 3b43d559b30..7ba13f1b85c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt @@ -672,6 +672,10 @@ internal class ChannelStateLogic( mutableState.setMembersCount(channel.memberCount) + // The config must be set before updating the reads: the read merge checks the config + // to protect locally tracked reads from being overwritten. + mutableState.setChannelConfig(channel.config) + updateReads(channel.read) // there are some edge cases here, this code adds to the members, watchers and messages @@ -699,8 +703,6 @@ internal class ChannelStateLogic( } } - mutableState.setChannelConfig(channel.config) - mutableState.setLoadingOlderMessages(false) mutableState.setLoadingNewerMessages(false) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index d267696d417..e2b2372d9e5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -187,6 +187,14 @@ internal class QueryChannelsStateLogic( ) } }.forEach { it.await() } + // The channels set above come from the raw payload. For channels with read events disabled + // the locally tracked read state is authoritative over the payload's read data, so + // reconcile those channels from the per-channel state populated by the merge above. + validated + .filter { (_, channel) -> !channel.config.readEventsEnabled } + .map { (id, _) -> id.cid } + .takeIf { it.isNotEmpty() } + ?.let(::refreshChannels) } private fun Channel.joinMessages(existingChannel: Channel?): Channel = diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt index 25aa2e532ca..1e4bb41f065 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImplTest.kt @@ -51,6 +51,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -1073,6 +1074,29 @@ internal class ChannelLogicImplTest { verify(stateImpl).updateChannelData(any<(ChannelData?) -> ChannelData?>()) } + @Test + fun `should set the channel config before updating reads`() = runTest { + // The read merge checks the config to protect locally tracked reads, so the config + // must be up to date before the reads are merged. + val channel = randomChannel( + id = "123", + type = "messaging", + messages = emptyList(), + members = emptyList(), + watchers = emptyList(), + read = listOf(randomChannelUserRead()), + memberCount = 5, + watcherCount = 0, + ) + // When + sut.updateDataForChannel(channel = channel, messageLimit = 0) + // Then + inOrder(stateImpl) { + verify(stateImpl).setChannelConfig(channel.config) + verify(stateImpl).updateReads(channel.read) + } + } + @Test fun `should update member count`() = runTest { // Given diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt index dcea096a2a6..6da7ad43919 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt @@ -66,6 +66,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.spy @@ -921,6 +922,23 @@ internal class ChannelStateLogicTest { ) } + @Test + fun `When updateDataForChannel is called, Then the config is set before the reads are updated`() { + // The read merge checks the config to protect locally tracked reads, so the config + // must be up to date before the reads are merged. + val channel = randomChannel(read = listOf(randomChannelUserRead(user = user))) + + channelStateLogic.updateDataForChannel( + channel = channel, + messageLimit = 0, + ) + + inOrder(mutableState) { + verify(mutableState).setChannelConfig(channel.config) + verify(mutableState).upsertReads(any()) + } + } + @Test fun `Given locally tracked channel, When updateDataForChannel is called with more recent server read, Then local read state is preserved`() { // given - local unread count enabled, read events disabled, locally incremented count diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt index 8bb79719c79..da82b2eb9d8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogicTest.kt @@ -29,6 +29,8 @@ import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomChannelUserRead +import io.getstream.chat.android.randomConfig import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString @@ -46,6 +48,7 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -138,6 +141,43 @@ internal class QueryChannelsStateLogicTest { verify(mutableState).setChannels(channels.associateBy { it.cid }) } + @Test + fun `when adding channels with read events disabled, the channel is reconciled from the state`() = runTest { + // The payload of a read-events-disabled channel carries read data the locally tracked + // unread count must win over, so the added channel is rebuilt from the per-channel state. + val payloadChannel = randomChannel( + type = type, + id = id, + config = randomConfig(readEventsEnabled = false), + ) + val stateChannel = payloadChannel.copy( + read = listOf(randomChannelUserRead(unreadMessages = 3)), + ) + val channelState: ChannelState = mock { + on(it.toChannel()) doReturn stateChannel + } + whenever(stateRegistry.isActiveChannel(testChannelId)) doReturn true + whenever(stateRegistry.channel(testChannelId)) doReturn channelState + + queryChannelsStateLogic.addChannelsState(listOf(payloadChannel)) + + verify(mutableState).setChannels(mapOf(testCid to stateChannel)) + } + + @Test + fun `when adding channels with read events enabled, the channel is not reconciled from the state`() = runTest { + val payloadChannel = randomChannel( + type = type, + id = id, + config = randomConfig(readEventsEnabled = true), + ) + + queryChannelsStateLogic.addChannelsState(listOf(payloadChannel)) + + // Only the payload channels are set; no reconciliation pass runs + verify(mutableState, times(1)).setChannels(any()) + } + @Test fun `getActiveChannelState should return channel when it is active in state registry`() { val channel = randomChannel(type = type, id = id) From fcee8c6664185704dd95631b303aa8fb3aafc752 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:18:01 +0000 Subject: [PATCH 09/21] client: Skip unread count updates for messages already in the channel state --- .../legacy/ChannelEventHandlerLegacyImpl.kt | 10 +++--- .../internal/legacy/ChannelStateLogic.kt | 7 ++++ .../channel/internal/ChannelStateImpl.kt | 8 +++++ .../internal/legacy/ChannelStateLogicTest.kt | 31 ++++++++++++++++++ .../ChannelStateImplLocalUnreadCountTest.kt | 32 +++++++++++++++++-- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt index c24cb8f62f4..f61ca6f5c2b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt @@ -117,9 +117,10 @@ internal class ChannelEventHandlerLegacyImpl( // Preserve createdLocallyAt only for messages created by current user, to ensure they are // sorted properly val preserveCreatedLocallyAt = event.message.user.id == getCurrentUserId() - upsertMessage(event.message, preserveCreatedLocallyAt) - // Update channel read state + // Update channel read state. Must happen before the message is upserted: a message + // already present in the state does not update the unread count. stateLogic.updateCurrentUserRead(event.createdAt, event.message) + upsertMessage(event.message, preserveCreatedLocallyAt) // Update hidden state if the message is not shadowed if (!event.message.shadowed) { stateLogic.setHidden(false) @@ -131,11 +132,12 @@ internal class ChannelEventHandlerLegacyImpl( } is NotificationMessageNewEvent -> { + // Update channel read state. Must happen before the message is upserted: a message + // already present in the state does not update the unread count. + stateLogic.updateCurrentUserRead(event.createdAt, event.message) if (!mutableState.insideSearch.value) { upsertMessage(event.message) } - // 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) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt index 7ba13f1b85c..4de3c70e27f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt @@ -919,6 +919,13 @@ internal class ChannelStateLogic( if (isProcessed) { return } + // Skip update if the message is already part of the channel state: it was already counted, or + // was loaded from the local database or a channel query (e.g. an event replayed by the sync + // after a restart, when the in-memory processed cache is empty) + if (mutableState.getMessageById(message.id) != null) { + processedMessageIds.put(message.id, true) + return + } // Skip update if the channel is muted val isMuted = mutableState.muted.value if (isMuted) { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index 655d25e2967..7a9d7ccf1bd 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -1074,6 +1074,14 @@ internal class ChannelStateImpl( logUnreadCountSkip(message, "message already processed") return } + // Skip update if the message is already part of the channel state: it was already counted, or + // was loaded from the local database or a channel query (e.g. an event replayed by the sync + // after a restart, when the in-memory processed cache is empty) + if (getMessageById(message.id) != null) { + logUnreadCountSkip(message, "message already in the channel state") + processedMessageIds.put(message.id, true) + return + } // Skip update if the channel is muted val isMuted = muted.value if (isMuted) { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt index 6da7ad43919..41ac679eb05 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt @@ -767,6 +767,37 @@ internal class ChannelStateLogicTest { verify(mutableState).upsertReads(eq(listOf(expectedChannelUserRead))) } + @Test + fun `Given message already in the channel state, When updateCurrentUserRead is called, Then unread count is not updated`() { + // After a restart the sync replays the events since the last sync, including the event of + // the newest message already counted (and persisted) before the restart. That message is + // already part of the state seeded from the database, so it must not be counted twice. + val initialChannelUserRead = randomChannelUserRead( + user = user, + lastReceivedEventDate = Date(10L), + unreadMessages = 1, + lastRead = Date(10L), + lastReadMessageId = randomString(), + ) + _read.value = initialChannelUserRead + + val eventDate = Date(10L) + val newMessage = randomMessage( + user = randomUser(id = "anotherUserId"), + createdAt = eventDate, + silent = false, + shadowed = false, + parentId = null, + ) + whenever(mutableState.getMessageById(newMessage.id)) doReturn newMessage + + // when + channelStateLogic.updateCurrentUserRead(eventDate, newMessage) + + // then + verify(mutableState, times(0)).upsertReads(any()) + } + @Test fun `Given no current read state exists, When updateCurrentUserRead is called, Then unread count is not updated`() { // given - no current read state diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt index 2d7b1c139de..b019f3de8d2 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt @@ -116,18 +116,44 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() val state = localTrackingState() state.setChannelConfig(Config(readEventsEnabled = false)) val otherUser = randomUser(id = "other_user") - // first message arrives -> unread = 1 + // first message arrives -> unread = 1 (the read is updated before the message is upserted, + // mirroring the event handling order) val firstMessage = createMessage(1, timestamp = 1000, user = otherUser) - state.setMessages(listOf(firstMessage)) state.updateCurrentUserRead(Date(1000), firstMessage) + state.setMessages(listOf(firstMessage)) assertEquals(1, state.unreadCount.value) // user opens the channel -> local mark read resets to 0 state.markRead() assertEquals(0, state.unreadCount.value) // a later message arrives -> unread = 1 again, not 2 val secondMessage = createMessage(2, timestamp = 5000, user = otherUser) - state.setMessages(listOf(firstMessage, secondMessage)) state.updateCurrentUserRead(Date(5000), secondMessage) + state.setMessages(listOf(firstMessage, secondMessage)) + assertEquals(1, state.unreadCount.value) + } + + @Test + fun `updateCurrentUserRead does not count a replayed event for a message already in the state`() = runTest { + // After a restart the sync replays the events since the last sync, including the event of + // the newest message already counted (and persisted) before the restart. That message is + // already part of the state seeded from the database, so it must not be counted twice. + val state = localTrackingState() + state.setChannelConfig(Config(readEventsEnabled = false)) + val otherUser = randomUser(id = "other_user") + val message = createMessage(1, timestamp = 5000, user = otherUser) + // Seeded from the database: the message and the read counting it + state.setMessages(listOf(message)) + state.updateRead( + createRead( + user = currentUser, + unreadMessages = 1, + lastRead = Date(1000), + lastReceivedEventDate = Date(5000), + ), + ) + // when: the sync replays the message event + state.updateCurrentUserRead(Date(5000), message) + // then: the count is not incremented again assertEquals(1, state.unreadCount.value) } From 615c216756902521f91db58b419b4b7f4bd00756 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:25:07 +0000 Subject: [PATCH 10/21] client: Serialize the channel cache merges to keep persisted reads convergent --- .../internal/DatabaseChannelRepository.kt | 52 ++++++++++++------- .../internal/EventHandlerSequential.kt | 2 - 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt index 4b6ff67e93a..f49bd582aa9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt @@ -56,6 +56,13 @@ internal class DatabaseChannelRepository( private val channelCache = LruCache(cacheSize) private val dbMutex = Mutex() + /** + * Guards the read-merge-cache sequences of [insertChannels] and [upsertChannelReads]: without + * it, concurrent writers (e.g. a channel list refresh racing a local mark-read persist) could + * interleave and put an outdated merge result into the cache. + */ + private val cacheMutex = Mutex() + override suspend fun insertChannel(channel: Channel) { insertChannels(listOf(channel)) } @@ -67,20 +74,21 @@ internal class DatabaseChannelRepository( */ override suspend fun insertChannels(channels: Collection) { if (channels.isEmpty()) return - val updatedChannels = channels - .map { channelCache[it.cid]?.let { cachedChannel -> it.combine(cachedChannel) } ?: it } - .map { if (it.config.readEventsEnabled) it else it.preserveLocallyTrackedReads() } - val channelToInsert = updatedChannels - .filter { channelCache[it.cid] != it } - .map { it.toEntity() } - cacheChannel(updatedChannels) + val channelsToInsert = cacheMutex.withLock { + val updatedChannels = channels + .map { channelCache[it.cid]?.let { cachedChannel -> it.combine(cachedChannel) } ?: it } + .map { if (it.config.readEventsEnabled) it else it.preserveLocallyTrackedReads() } + updatedChannels + .filter { channelCache[it.cid] != it } + .also { cacheChannel(updatedChannels) } + } scope.launchWithMutex(dbMutex) { - logger.v { - "[insertChannels] inserting ${channelToInsert.size} entities on DB, " + - "updated ${updatedChannels.size} on cache" - } - channelToInsert + logger.v { "[insertChannels] inserting ${channelsToInsert.size} entities on DB" } + channelsToInsert .takeUnless { it.isEmpty() } + // Re-read the cache at write time: DAO writes are not guaranteed to run in launch + // order, so the last write must persist the latest merged state, not its snapshot + ?.map { channel -> (channelCache[channel.cid] ?: channel).toEntity() } ?.let { channelDao.insertMany(it) } } } @@ -122,19 +130,23 @@ internal class DatabaseChannelRepository( /** * Upserts the given [reads] into the stored channel, replacing the stored reads of the same * users and keeping the rest. The write is applied verbatim (no merge against the stored read - * state): the caller is the source of truth for these reads. Ordering with concurrent - * [insertChannels] writes for the same channel is guaranteed by the shared [dbMutex]. + * state): the caller is the source of truth for these reads. The cache merge runs under + * [cacheMutex] so concurrent [insertChannels] merges cannot interleave with it, and the DAO + * writes re-read the cache at write time, so the persisted row converges to the latest merged + * state regardless of the order in which the writes land. */ override suspend fun upsertChannelReads(cid: String, reads: List) { if (reads.isEmpty()) return - val stored = selectChannel(cid) ?: return - val updatedChannel = stored - .copy(read = (reads + stored.read).distinctBy(ChannelUserRead::getUserId)) - .syncUnreadCountWithReads() - cacheChannel(updatedChannel) + val updatedChannel = cacheMutex.withLock { + val stored = selectChannel(cid) ?: return + stored + .copy(read = (reads + stored.read).distinctBy(ChannelUserRead::getUserId)) + .syncUnreadCountWithReads() + .also { cacheChannel(it) } + } scope.launchWithMutex(dbMutex) { logger.v { "[upsertChannelReads] cid: $cid, reads.size: ${reads.size}" } - channelDao.insert(updatedChannel.toEntity()) + channelDao.insert((channelCache[cid] ?: updatedChannel).toEntity()) } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index c915343fa0e..c4f57c1a81b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -893,8 +893,6 @@ internal class EventHandlerSequential( // execute the batch batch.execute() - // persist the locally tracked reads for the affected channels, after the batch has stored - // the server data persistLocallyTrackedReads(events) // handle delete and truncate events From 63a28a656057c033c8122b46c10a18e6dd5a30fa Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:52:26 +0000 Subject: [PATCH 11/21] client: Shorten unread count log messages --- .../internal/legacy/ChannelStateLogic.kt | 3 +- .../channel/internal/ChannelStateImpl.kt | 32 +++++++------------ 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt index 4de3c70e27f..a2141f6e0e0 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt @@ -241,8 +241,7 @@ internal class ChannelStateLogic( // with the local value anchored to the same message. Only the user info and the delivered // fields are merged from the server. logger.d { - "[updateReads] Read state is tracked locally, preserving: " + - "local.lastRead=${localRead.lastRead}, " + + "[updateReads] preserving locally tracked read, " + "local.unreadMessages=${localRead.unreadMessages}, " + "server.unreadMessages=${serverRead.unreadMessages}" } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index 7a9d7ccf1bd..d029da61c4d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -1071,28 +1071,28 @@ internal class ChannelStateImpl( // Skip update if the message was already processed val isProcessed = processedMessageIds[message.id] == true if (isProcessed) { - logUnreadCountSkip(message, "message already processed") + logUnreadCountSkip(message, "already processed") return } // Skip update if the message is already part of the channel state: it was already counted, or // was loaded from the local database or a channel query (e.g. an event replayed by the sync // after a restart, when the in-memory processed cache is empty) if (getMessageById(message.id) != null) { - logUnreadCountSkip(message, "message already in the channel state") + logUnreadCountSkip(message, "already in state") processedMessageIds.put(message.id, true) return } // Skip update if the channel is muted val isMuted = muted.value if (isMuted) { - logUnreadCountSkip(message, "channel is muted") + logUnreadCountSkip(message, "channel muted") processedMessageIds.put(message.id, true) return } // Skip update for thread replies not shown in channel val isThreadReplyNotInChannel = message.parentId != null && !message.showInChannel if (isThreadReplyNotInChannel) { - logUnreadCountSkip(message, "thread reply not shown in channel") + logUnreadCountSkip(message, "thread reply not in channel") processedMessageIds.put(message.id, true) return } @@ -1106,19 +1106,19 @@ internal class ChannelStateImpl( // Skip update for messages from muted users val isFromMutedUser = mutedUsers.value.any { it.target?.id == message.user.id } if (isFromMutedUser) { - logUnreadCountSkip(message, "message author is muted") + logUnreadCountSkip(message, "author muted") processedMessageIds.put(message.id, true) return } // Skip update for messages from shadow banned users if (message.shadowed) { - logUnreadCountSkip(message, "message is shadowed") + logUnreadCountSkip(message, "shadowed") processedMessageIds.put(message.id, true) return } // Skip update for silent messages if (message.silent) { - logUnreadCountSkip(message, "message is silent") + logUnreadCountSkip(message, "silent") processedMessageIds.put(message.id, true) return } @@ -1127,8 +1127,7 @@ internal class ChannelStateImpl( if (currentRead != null && currentRead.lastReceivedEventDate.after(eventReceivedDate)) { logUnreadCountSkip( message, - "event is outdated, read.lastReceivedEventDate: ${currentRead.lastReceivedEventDate}, " + - "eventReceivedDate: $eventReceivedDate", + "outdated (read: ${currentRead.lastReceivedEventDate}, event: $eventReceivedDate)", ) processedMessageIds.put(message.id, true) return @@ -1139,7 +1138,7 @@ internal class ChannelStateImpl( } private fun logUnreadCountSkip(message: Message, reason: String) { - logger.v { "[updateCurrentUserRead] cid: $cid, message: ${message.id} does not update unread count: $reason" } + logger.v { "[updateCurrentUserRead] cid: $cid, skipping ${message.id}: $reason" } } /** @@ -1180,10 +1179,7 @@ internal class ChannelStateImpl( null } updatedRead?.let { newRead -> - logger.v { - "[incrementUnreadCount] cid: $cid, unreadMessages: ${newRead.unreadMessages}, " + - "lastReceivedEventDate: ${newRead.lastReceivedEventDate}" - } + logger.v { "[incrementUnreadCount] cid: $cid, unreadMessages: ${newRead.unreadMessages}" } _reads.update { current -> current + (newRead.getUserId() to newRead) } @@ -1251,10 +1247,7 @@ internal class ChannelStateImpl( lastReadMessageId = lastMessage?.id ?: currentUserRead.lastReadMessageId, unreadMessages = 0, ) - logger.v { - "[markReadLocally] cid: $cid, lastRead: ${updatedRead.lastRead}, " + - "lastReceivedEventDate: ${updatedRead.lastReceivedEventDate}" - } + logger.v { "[markReadLocally] cid: $cid, lastRead: ${updatedRead.lastRead}" } _reads.update { current -> current + (updatedRead.getUserId() to updatedRead) } @@ -1716,8 +1709,7 @@ internal class ChannelStateImpl( // with the local value anchored to the same message. Only the user info and the delivered // fields are merged from the server. logger.d { - "[updateReads] Read state is tracked locally, preserving: " + - "local.lastRead=${localRead.lastRead}, " + + "[updateReads] preserving locally tracked read, " + "local.unreadMessages=${localRead.unreadMessages}, " + "server.unreadMessages=${serverRead.unreadMessages}" } From e3fec4c973f5460655d72cc39cf6e0c839003fc9 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:18:15 +0000 Subject: [PATCH 12/21] compose-sample: Show livestream channels when the local unread count is enabled --- .../chat/android/compose/sample/ChatHelper.kt | 17 ++++++++- .../feature/channel/list/ChannelsActivity.kt | 38 ++++++++++++++----- .../compose/sample/ui/chats/ChatsActivity.kt | 38 ++++++++++++++----- .../sample/ui/login/UserLoginActivity.kt | 6 +-- .../src/main/res/values/strings.xml | 2 +- 5 files changed, 74 insertions(+), 27 deletions(-) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt index 8cf16581215..dfa0002aa16 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ChatHelper.kt @@ -47,6 +47,20 @@ object ChatHelper { var apiKey: String = "" private set + /** The value of the local unread count setting the SDK was initialized with. */ + private var isLocalUnreadCountEnabled: Boolean = false + + /** + * Initializes the SDK, unless it is already initialized with the given API key and the current settings. + */ + fun ensureInitialized(context: Context, apiKey: String) { + if (this.apiKey != apiKey || + isLocalUnreadCountEnabled != context.customSettings().isLocalUnreadCountEnabled + ) { + initializeSdk(context, apiKey) + } + } + /** * Initializes the SDK with the given API key. */ @@ -84,9 +98,10 @@ object ChatHelper { }, ) + this.isLocalUnreadCountEnabled = context.customSettings().isLocalUnreadCountEnabled val chatClientConfig = ChatClientConfig( userPresence = true, - isLocalUnreadCountEnabled = context.customSettings().isLocalUnreadCountEnabled, + isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, ) val logLevel = if (BuildConfig.DEBUG) ChatLogLevel.ALL else ChatLogLevel.NOTHING diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt index d377cb3c539..7d3066c74ad 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt @@ -90,8 +90,10 @@ import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory import io.getstream.chat.android.compose.viewmodel.threads.ThreadsViewModelFactory import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread +import io.getstream.chat.android.models.querysort.QuerySortByField import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope @@ -105,7 +107,10 @@ class ChannelsActivity : ComponentActivity() { private val settings by lazy { customSettings() } /** - * The provided predefined filter has the following specs: + * When the local unread count is enabled ([CustomSettings.isLocalUnreadCountEnabled]), an explicit + * filter including livestream channels is used, to make the feature testable: livestream channels + * have read events disabled server-side. Otherwise, the predefined server-side filter is used, + * which has the following specs: * * **Filter:** * ``` @@ -124,15 +129,28 @@ class ChannelsActivity : ComponentActivity() { private val channelsViewModelFactory by lazy { val chatClient = ChatClient.instance() val currentUserId = chatClient.getCurrentUser()?.id ?: "" - ChannelListViewModelFactory( - chatClient = chatClient, - predefinedFilterName = "android_sample_filter", - filterValues = mapOf( - "channel_type" to "messaging", - "user_id" to currentUserId, - ), - chatEventHandlerFactory = CustomChatEventHandlerFactory(), - ) + if (settings.isLocalUnreadCountEnabled) { + ChannelListViewModelFactory( + chatClient = chatClient, + querySort = QuerySortByField().desc("pinned_at").desc("last_updated"), + filters = Filters.and( + Filters.`in`("type", listOf("messaging", "livestream")), + Filters.`in`("members", listOf(currentUserId)), + Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), + ), + chatEventHandlerFactory = CustomChatEventHandlerFactory(), + ) + } else { + ChannelListViewModelFactory( + chatClient = chatClient, + predefinedFilterName = "android_sample_filter", + filterValues = mapOf( + "channel_type" to "messaging", + "user_id" to currentUserId, + ), + chatEventHandlerFactory = CustomChatEventHandlerFactory(), + ) + } } private val channelsViewModel: ChannelListViewModel by viewModels { channelsViewModelFactory } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt index e45ec872d96..1df7d56d92c 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt @@ -93,7 +93,9 @@ import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewM import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewModelFactory import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewEvent import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewEvent import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState @@ -133,7 +135,10 @@ class ChatsActivity : ComponentActivity() { private val settings by lazy { customSettings() } /** - * The provided predefined filter has the following specs: + * When the local unread count is enabled ([CustomSettings.isLocalUnreadCountEnabled]), an explicit + * filter including livestream channels is used, to make the feature testable: livestream channels + * have read events disabled server-side. Otherwise, the predefined server-side filter is used, + * which has the following specs: * * **Filter:** * ``` @@ -152,15 +157,28 @@ class ChatsActivity : ComponentActivity() { private val channelListViewModelFactory by lazy { val chatClient = ChatClient.instance() val currentUserId = chatClient.getCurrentUser()?.id ?: "" - ChannelListViewModelFactory( - chatClient = chatClient, - predefinedFilterName = "android_sample_filter", - filterValues = mapOf( - "channel_type" to "messaging", - "user_id" to currentUserId, - ), - chatEventHandlerFactory = CustomChatEventHandlerFactory(), - ) + if (settings.isLocalUnreadCountEnabled) { + ChannelListViewModelFactory( + chatClient = chatClient, + querySort = QuerySortByField().desc("pinned_at").desc("last_updated"), + filters = Filters.and( + Filters.`in`("type", listOf("messaging", "livestream")), + Filters.`in`("members", listOf(currentUserId)), + Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), + ), + chatEventHandlerFactory = CustomChatEventHandlerFactory(), + ) + } else { + ChannelListViewModelFactory( + chatClient = chatClient, + predefinedFilterName = "android_sample_filter", + filterValues = mapOf( + "channel_type" to "messaging", + "user_id" to currentUserId, + ), + chatEventHandlerFactory = CustomChatEventHandlerFactory(), + ) + } } private val channelViewModelFactory by lazy { diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/UserLoginActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/UserLoginActivity.kt index 344cf15157e..7cc32cf0e20 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/UserLoginActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/login/UserLoginActivity.kt @@ -85,11 +85,7 @@ class UserLoginActivity : AppCompatActivity() { UserLoginScreen( onUserItemClick = { userCredentials -> lifecycleScope.launch { - if (ChatHelper.apiKey != userCredentials.apiKey) { - // If the user attempted to login with custom credentials on the custom - // login screen then we need to reinitialize the SDK with our API key. - ChatHelper.initializeSdk(applicationContext, userCredentials.apiKey) - } + ChatHelper.ensureInitialized(applicationContext, userCredentials.apiKey) ChatHelper.connectUser( userCredentials = userCredentials, onSuccess = ::openChannels, diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index 2feae65a6ff..373f765ce9f 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -46,7 +46,7 @@ Channel pinning Show the Pin/Unpin Chat action in the channel options menu Local unread count - Track the unread count locally for channels with read events disabled (e.g. livestreams) + Track the unread count locally for channels with read events disabled. Also shows livestream channels in the channel list. Applied on the next login or app restart Pinned Messages From c813430422dfbc58f2a180dc87b25afd231db142 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:06:02 +0200 Subject: [PATCH 13/21] Condense local unread count comments and logs --- .../internal/DatabaseChannelRepository.kt | 25 ++------- .../internal/EventHandlerSequential.kt | 9 +--- .../internal/ChannelMarkReadListenerState.kt | 3 +- .../channel/internal/ChannelLogicImpl.kt | 16 ++---- .../legacy/ChannelEventHandlerLegacyImpl.kt | 6 +-- .../internal/legacy/ChannelLogicLegacyImpl.kt | 4 +- .../internal/legacy/ChannelStateLogic.kt | 43 +++++---------- .../internal/QueryChannelsStateLogic.kt | 5 +- .../channel/internal/ChannelStateImpl.kt | 53 ++++++------------- .../internal/ChannelStateLegacyImpl.kt | 14 ++--- .../repository/ChannelRepository.kt | 18 ++----- .../feature/channel/list/ChannelsActivity.kt | 22 ++------ .../compose/sample/ui/chats/ChatsActivity.kt | 22 ++------ 13 files changed, 64 insertions(+), 176 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt index f49bd582aa9..8c7ea6ef7d8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt @@ -55,12 +55,6 @@ internal class DatabaseChannelRepository( private val logger by taggedLogger("Chat:ChannelRepository") private val channelCache = LruCache(cacheSize) private val dbMutex = Mutex() - - /** - * Guards the read-merge-cache sequences of [insertChannels] and [upsertChannelReads]: without - * it, concurrent writers (e.g. a channel list refresh racing a local mark-read persist) could - * interleave and put an outdated merge result into the cache. - */ private val cacheMutex = Mutex() override suspend fun insertChannel(channel: Channel) { @@ -94,14 +88,9 @@ internal class DatabaseChannelRepository( } /** - * For channels with server-side read events disabled the per-channel unread count is tracked - * on-device, so a server payload must never overwrite the stored current user read. Note that - * a recency check is not enough: server reads carry lastReceivedEventDate = last_message_at, - * which ties with the locally tracked value anchored to the same message. The locally tracked - * reads reach the database through [upsertChannelReads], which bypasses this merge, so every - * [insertChannels] write for these channels' reads is server-sourced by construction. For - * other users' reads a recency check is applied. Falls back to the persisted row on a cache - * miss (e.g. after a process restart). + * Keeps the stored current user read of read-events-disabled channels: it is tracked on-device and + * must never be overwritten by server data (a recency check is not enough - server reads carry + * lastReceivedEventDate = last_message_at, tying it). Other users' reads are merged by recency. */ private suspend fun Channel.preserveLocallyTrackedReads(): Channel { val storedReads = channelCache[cid]?.read @@ -128,12 +117,8 @@ internal class DatabaseChannelRepository( } /** - * Upserts the given [reads] into the stored channel, replacing the stored reads of the same - * users and keeping the rest. The write is applied verbatim (no merge against the stored read - * state): the caller is the source of truth for these reads. The cache merge runs under - * [cacheMutex] so concurrent [insertChannels] merges cannot interleave with it, and the DAO - * writes re-read the cache at write time, so the persisted row converges to the latest merged - * state regardless of the order in which the writes land. + * The cache merge runs under [cacheMutex] and the DAO writes re-read the cache, so the persisted + * row converges to the latest merged state regardless of write order. */ override suspend fun upsertChannelReads(cid: String, reads: List) { if (reads.isEmpty()) return diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index c4f57c1a81b..533002bb396 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -976,13 +976,8 @@ internal class EventHandlerSequential( } /** - * For channels with server-side read events disabled, the per-channel unread count is tracked - * on-device in the channel state. This persists the current in-memory reads of the channels - * affected by new-message events, so the count survives a restart. The write goes through - * [RepositoryFacade.upsertChannelReads], which bypasses the repository's server-data merge; - * it runs after the batch has stored the server data, and the two writes are serialized by - * the repository's internal mutex. No-op when local unread count tracking is disabled, and - * for regular channels or channels that are not currently active in state. + * Persists the in-memory reads of the locally tracked channels affected by new-message events, so + * the on-device unread count survives a restart. Runs after the batch stored the server data. */ private suspend fun persistLocallyTrackedReads(events: List) { if (!isLocalUnreadCountEnabled) return diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt index c2aaf1d5f9d..7f22bac084b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerState.kt @@ -45,8 +45,7 @@ internal class ChannelMarkReadListenerState(private val logic: LogicRegistry) : return when (logic.channel(channelType, channelId).markRead()) { MarkReadResult.RemoteRequired -> Result.Success(Unit) MarkReadResult.HandledLocally -> { - // No server read event will follow a local mark-read, so refresh the channel in the - // active channel-list queries for the reset unread count to be reflected there. + // No server read event follows a local mark-read; refresh the channel-list queries directly. val cid = "$channelType:$channelId" logic.getActiveQueryChannelsLogic().forEach { it.refreshChannelState(cid) } Result.Failure(Error.GenericError("Channel $channelId marked as read locally")) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt index cda9eb95d6a..a4a281f97c4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/ChannelLogicImpl.kt @@ -136,8 +136,8 @@ internal class ChannelLogicImpl( state.setMemberCount(channel.memberCount) state.upsertMembers(channel.members) state.upsertWatchers(channel.watchers, channel.watcherCount) - // Update config. Must happen before updating the reads: the read merge checks - // the config to protect locally tracked reads from being overwritten. + // The config must be set before the reads: the read merge checks it to protect + // locally tracked reads. state.setChannelConfig(channel.config) // Update reads state.updateReads(channel.read) @@ -250,18 +250,12 @@ internal class ChannelLogicImpl( override fun markRead(): MarkReadResult { val result = state.markRead() if (result == MarkReadResult.HandledLocally) { - // Local mark-read only mutates in-memory state; persist the reset read so the on-device - // unread count survives a restart. persistCurrentReads() } return result } - /** - * Persists the current in-memory reads to the database. Used by the on-device unread tracking - * path, where the read state is not backed by a server response. Writes through - * [ChannelRepository.upsertChannelReads], which bypasses the repository's server-data merge. - */ + /** Persists the in-memory reads so the locally tracked unread count survives a restart. */ private fun persistCurrentReads() { val reads = state.reads.value if (reads.isEmpty()) return @@ -313,8 +307,8 @@ internal class ChannelLogicImpl( state.setMemberCount(channel.memberCount) state.upsertMembers(channel.members) state.upsertWatchers(channel.watchers, channel.watcherCount) - // Update channel config. Must happen before updating the reads: the read merge checks - // the config to protect locally tracked reads from being overwritten. + // The config must be set before the reads: the read merge checks it to protect + // locally tracked reads. state.setChannelConfig(channel.config) // Update reads state.updateReads(channel.read) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt index f61ca6f5c2b..391deefdd4d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelEventHandlerLegacyImpl.kt @@ -117,8 +117,7 @@ internal class ChannelEventHandlerLegacyImpl( // Preserve createdLocallyAt only for messages created by current user, to ensure they are // sorted properly val preserveCreatedLocallyAt = event.message.user.id == getCurrentUserId() - // Update channel read state. Must happen before the message is upserted: a message - // already present in the state does not update the unread count. + // Must run before the upsert: messages already in the state do not update the unread count. stateLogic.updateCurrentUserRead(event.createdAt, event.message) upsertMessage(event.message, preserveCreatedLocallyAt) // Update hidden state if the message is not shadowed @@ -132,8 +131,7 @@ internal class ChannelEventHandlerLegacyImpl( } is NotificationMessageNewEvent -> { - // Update channel read state. Must happen before the message is upserted: a message - // already present in the state does not update the unread count. + // Must run before the upsert: messages already in the state do not update the unread count. stateLogic.updateCurrentUserRead(event.createdAt, event.message) if (!mutableState.insideSearch.value) { upsertMessage(event.message) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt index 1c75196ddc1..83147f64996 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelLogicLegacyImpl.kt @@ -203,9 +203,7 @@ internal class ChannelLogicLegacyImpl( override fun markRead(): MarkReadResult { val result = stateLogic.markRead() if (result == MarkReadResult.HandledLocally) { - // Local mark-read only mutates in-memory state; persist the reset read so the on-device - // unread count survives a restart. The write goes through upsertChannelReads, which - // bypasses the repository's server-data merge. + // Persist the reset read so the on-device unread count survives a restart. val reads = mutableState.reads.value if (reads.isNotEmpty()) { coroutineScope.launch { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt index a2141f6e0e0..2fe0fe8ffb2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogic.kt @@ -82,10 +82,6 @@ internal class ChannelStateLogic( private val logger by taggedLogger(TAG) private val processedMessageIds = LruCache(CACHE_SIZE) - /** - * `true` if the per-channel unread count is tracked locally for this channel, i.e. local unread count tracking - * is enabled and read events are disabled server-side. - */ private val isReadTrackedLocally: Boolean get() = mutableState.isLocalUnreadCountEnabled && !mutableState.channelConfig.value.readEventsEnabled @@ -235,15 +231,14 @@ internal class ChannelStateLogic( serverRead: ChannelUserRead, ): ChannelUserRead { if (isReadTrackedLocally) { - // The unread count is tracked locally for this channel (read events are disabled server-side), - // so the server data must never overwrite the locally tracked read state. Note that a recency - // check is not enough: server reads carry lastReceivedEventDate = last_message_at, which ties - // with the local value anchored to the same message. Only the user info and the delivered - // fields are merged from the server. - logger.d { - "[updateReads] preserving locally tracked read, " + - "local.unreadMessages=${localRead.unreadMessages}, " + - "server.unreadMessages=${serverRead.unreadMessages}" + // Locally tracked reads must never be overwritten by server data. A recency check is not + // enough: server reads carry lastReceivedEventDate = last_message_at, tying the local value. + if (localRead.unreadMessages != serverRead.unreadMessages) { + logger.d { + "[updateReads] preserving locally tracked read, " + + "local.unreadMessages=${localRead.unreadMessages}, " + + "server.unreadMessages=${serverRead.unreadMessages}" + } } return localRead.copy( user = serverRead.user, @@ -671,8 +666,8 @@ internal class ChannelStateLogic( mutableState.setMembersCount(channel.memberCount) - // The config must be set before updating the reads: the read merge checks the config - // to protect locally tracked reads from being overwritten. + // The config must be set before the reads: the read merge checks it to protect + // locally tracked reads. mutableState.setChannelConfig(channel.config) updateReads(channel.read) @@ -918,9 +913,8 @@ internal class ChannelStateLogic( if (isProcessed) { return } - // Skip update if the message is already part of the channel state: it was already counted, or - // was loaded from the local database or a channel query (e.g. an event replayed by the sync - // after a restart, when the in-memory processed cache is empty) + // Skip messages already in the channel state - e.g. a sync-replayed event after a restart, + // when the in-memory processed cache is empty if (mutableState.getMessageById(message.id) != null) { processedMessageIds.put(message.id, true) return @@ -970,15 +964,7 @@ internal class ChannelStateLogic( processedMessageIds.put(message.id, true) } - /** - * Increments the current user's unread count for a newly received message. Creates the read - * state on-the-fly when local tracking is enabled and none exists yet (read-events-disabled - * channels omit it). - * - * The updated read is upserted directly, bypassing the merge in [updateReads] which is reserved - * for server data - the outdated event check in [updateCurrentUserRead] already guarantees this - * update is the most recent one. - */ + /** The incremented read is upserted directly: the merge in [updateReads] is for server data. */ private fun incrementUnreadCount(currentRead: ChannelUserRead?, eventReceivedDate: Date) { val updatedRead = if (currentRead != null) { currentRead.copy( @@ -991,8 +977,7 @@ internal class ChannelStateLogic( user = user, lastReceivedEventDate = eventReceivedDate, unreadMessages = 1, - // Only the count is tracked locally; lastRead/lastReadMessageId are left unset - // because there is no server read to anchor the unread separator to. + // lastRead/lastReadMessageId are unset: there is no server read to anchor them to. lastRead = Date(0), lastReadMessageId = null, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index e2b2372d9e5..7387716cee9 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -187,9 +187,8 @@ internal class QueryChannelsStateLogic( ) } }.forEach { it.await() } - // The channels set above come from the raw payload. For channels with read events disabled - // the locally tracked read state is authoritative over the payload's read data, so - // reconcile those channels from the per-channel state populated by the merge above. + // For read-events-disabled channels the locally tracked read is authoritative over the raw + // payload set above, so re-derive those channels from the merged per-channel state. validated .filter { (_, channel) -> !channel.config.readEventsEnabled } .map { (id, _) -> id.cid } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt index d029da61c4d..dc12b04ed92 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImpl.kt @@ -1074,9 +1074,8 @@ internal class ChannelStateImpl( logUnreadCountSkip(message, "already processed") return } - // Skip update if the message is already part of the channel state: it was already counted, or - // was loaded from the local database or a channel query (e.g. an event replayed by the sync - // after a restart, when the in-memory processed cache is empty) + // Skip messages already in the channel state - e.g. a sync-replayed event after a restart, + // when the in-memory processed cache is empty if (getMessageById(message.id) != null) { logUnreadCountSkip(message, "already in state") processedMessageIds.put(message.id, true) @@ -1141,22 +1140,10 @@ internal class ChannelStateImpl( logger.v { "[updateCurrentUserRead] cid: $cid, skipping ${message.id}: $reason" } } - /** - * `true` if the per-channel unread count is tracked locally for this channel, i.e. local unread count tracking - * is enabled and read events are disabled server-side. - */ private val isReadTrackedLocally: Boolean get() = isLocalUnreadCountEnabled && !channelConfig.value.readEventsEnabled - /** - * Increments the current user's unread count for a newly received message. Creates the read - * state on-the-fly when local tracking is enabled and none exists yet (read-events-disabled - * channels omit it). - * - * The updated read is upserted directly, bypassing the merge in [updateReads] which is reserved - * for server data - the outdated event check in [updateCurrentUserRead] already guarantees this - * update is the most recent one. - */ + /** The incremented read is upserted directly: the merge in [updateReads] is for server data. */ private fun incrementUnreadCount(currentRead: ChannelUserRead?, eventReceivedDate: Date) { val updatedRead = if (currentRead != null) { currentRead.copy( @@ -1169,8 +1156,7 @@ internal class ChannelStateImpl( user = user, lastReceivedEventDate = eventReceivedDate, unreadMessages = 1, - // Only the count is tracked locally; lastRead/lastReadMessageId are left unset - // because there is no server read to anchor the unread separator to. + // lastRead/lastReadMessageId are unset: there is no server read to anchor them to. lastRead = Date(0), lastReadMessageId = null, ) @@ -1187,13 +1173,8 @@ internal class ChannelStateImpl( } /** - * Marks the channel as read for the current user. - * - * When the channel has server-side read events disabled, the unread count is either reset - * on-device ([MarkReadResult.HandledLocally]) if local tracking is enabled, or the request is ignored - * ([MarkReadResult.NotNeeded]). Otherwise the channel is marked as read remotely - * ([MarkReadResult.RemoteRequired]) when there are unread messages, or left untouched - * ([MarkReadResult.NotNeeded]) when it is already up to date. + * Marks the channel as read for the current user and returns how the request was handled: + * remotely, on-device (read events disabled + local tracking enabled), or not at all. */ fun markRead(): MarkReadResult { if (!channelConfig.value.readEventsEnabled) { @@ -1233,9 +1214,8 @@ internal class ChannelStateImpl( } /** - * Resets the current user's unread count on-device by advancing the read state to the latest - * message without a network request. Local tracking sets [ChannelUserRead.lastReadMessageId] - * itself, unlike the remote path which waits for the server to confirm it. + * Resets the current user's unread count on-device, advancing the read state (including + * [ChannelUserRead.lastReadMessageId], which the remote path leaves to the server). */ private fun markReadLocally() { val currentUserRead = read.value ?: return @@ -1703,15 +1683,14 @@ internal class ChannelStateImpl( serverRead: ChannelUserRead, ): ChannelUserRead { if (isReadTrackedLocally) { - // The unread count is tracked locally for this channel (read events are disabled server-side), - // so the server data must never overwrite the locally tracked read state. Note that a recency - // check is not enough: server reads carry lastReceivedEventDate = last_message_at, which ties - // with the local value anchored to the same message. Only the user info and the delivered - // fields are merged from the server. - logger.d { - "[updateReads] preserving locally tracked read, " + - "local.unreadMessages=${localRead.unreadMessages}, " + - "server.unreadMessages=${serverRead.unreadMessages}" + // Locally tracked reads must never be overwritten by server data. A recency check is not + // enough: server reads carry lastReceivedEventDate = last_message_at, tying the local value. + if (localRead.unreadMessages != serverRead.unreadMessages) { + logger.d { + "[updateReads] preserving locally tracked read, " + + "local.unreadMessages=${localRead.unreadMessages}, " + + "server.unreadMessages=${serverRead.unreadMessages}" + } } return localRead.copy( user = serverRead.user, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt index dc4c10e17cd..0d795df2e64 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImpl.kt @@ -592,13 +592,8 @@ internal class ChannelStateLegacyImpl( } /** - * Marks the channel as read for the current user. - * - * When the channel has server-side read events disabled, the unread count is either reset - * on-device ([MarkReadResult.HandledLocally]) if local tracking is enabled, or the request is ignored - * ([MarkReadResult.NotNeeded]). Otherwise the channel is marked as read remotely - * ([MarkReadResult.RemoteRequired]) when there are unread messages, or left untouched - * ([MarkReadResult.NotNeeded]) when it is already up to date. + * Marks the channel as read for the current user and returns how the request was handled: + * remotely, on-device (read events disabled + local tracking enabled), or not at all. */ fun markChannelAsRead(): MarkReadResult { if (!channelConfig.value.readEventsEnabled) { @@ -629,9 +624,8 @@ internal class ChannelStateLegacyImpl( } /** - * Resets the current user's unread count on-device by advancing the read state to the latest - * message without a network request. Local tracking sets [ChannelUserRead.lastReadMessageId] - * itself, unlike the remote path which waits for the server to confirm it. + * Resets the current user's unread count on-device, advancing the read state (including + * [ChannelUserRead.lastReadMessageId], which the remote path leaves to the server). */ private fun markReadLocally() { val currentUserRead = read.value ?: return diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt index 67c3e958d5b..e287bec096b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt @@ -157,20 +157,10 @@ public interface ChannelRepository { public suspend fun updateLastMessageForChannel(cid: String, lastMessage: Message) /** - * Upserts the given [reads] into the stored channel, replacing the stored reads of the same - * users and keeping the rest. - * - * Used by the local unread count tracking (`ChatClientConfig.isLocalUnreadCountEnabled`) to - * persist reads that are not backed by a server response, for channels with server-side read - * events disabled. Unlike [insertChannel], the write is applied verbatim - it is not merged - * against the stored read state, because the caller (the in-memory channel state) is the - * source of truth for these reads. - * - * The default implementation is a no-op: custom [ChannelRepository] implementations must - * override it for the locally tracked unread count to survive across app restarts. - * - * @param cid The full channel id of the channel to update, ie messaging:123. - * @param reads The reads to upsert. + * Upserts the given [reads] into the channel stored with the given [cid] (ie messaging:123), + * replacing the stored reads of the same users. Used by the local unread count tracking: unlike + * [insertChannel], the write is applied verbatim - the caller is the source of truth. + * The default no-op must be overridden for the locally tracked count to survive app restarts. */ public suspend fun upsertChannelReads(cid: String, reads: List) { // no-op by default diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt index 7d3066c74ad..b0629752a40 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt @@ -107,24 +107,10 @@ class ChannelsActivity : ComponentActivity() { private val settings by lazy { customSettings() } /** - * When the local unread count is enabled ([CustomSettings.isLocalUnreadCountEnabled]), an explicit - * filter including livestream channels is used, to make the feature testable: livestream channels - * have read events disabled server-side. Otherwise, the predefined server-side filter is used, - * which has the following specs: - * - * **Filter:** - * ``` - * Filters.and( - * Filters.eq("type", "messaging"), - * Filters.`in`("members", listOf(currentUserId)), - * Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), - * ) - * ``` - * - * **Sort:** - * ``` - * QuerySortByField().desc("pinned_at").desc("last_updated") - * ``` + * When the local unread count is enabled, an explicit filter including livestream channels is used, + * to make the feature testable. Otherwise, the predefined server-side filter is used, which resolves + * to: messaging channels the current user is a member of, without a draft, sorted by "pinned_at" and + * "last_updated" descending. */ private val channelsViewModelFactory by lazy { val chatClient = ChatClient.instance() diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt index 1df7d56d92c..07b0b3967dc 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt @@ -135,24 +135,10 @@ class ChatsActivity : ComponentActivity() { private val settings by lazy { customSettings() } /** - * When the local unread count is enabled ([CustomSettings.isLocalUnreadCountEnabled]), an explicit - * filter including livestream channels is used, to make the feature testable: livestream channels - * have read events disabled server-side. Otherwise, the predefined server-side filter is used, - * which has the following specs: - * - * **Filter:** - * ``` - * Filters.and( - * Filters.eq("type", "messaging"), - * Filters.`in`("members", listOf(currentUserId)), - * Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), - * ) - * ``` - * - * **Sort:** - * ``` - * QuerySortByField().desc("pinned_at").desc("last_updated") - * ``` + * When the local unread count is enabled, an explicit filter including livestream channels is used, + * to make the feature testable. Otherwise, the predefined server-side filter is used, which resolves + * to: messaging channels the current user is a member of, without a draft, sorted by "pinned_at" and + * "last_updated" descending. */ private val channelListViewModelFactory by lazy { val chatClient = ChatClient.instance() From a6de7bbf06bce2fdfac0ecec2f882c3540bbfd44 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:06:02 +0200 Subject: [PATCH 14/21] client: Simplify local unread count tests --- .../internal/ChannelRepositoryImplTest.kt | 138 +++-------- .../ChannelStateImplLocalUnreadCountTest.kt | 227 +++++++----------- ...nnelStateLegacyImplLocalUnreadCountTest.kt | 11 +- 3 files changed, 127 insertions(+), 249 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt index 390b39aaf26..121d80404c8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelRepositoryImplTest.kt @@ -30,6 +30,9 @@ import org.junit.Rule import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor @@ -82,127 +85,36 @@ internal class ChannelRepositoryImplTest { verify(channelDao, never()).insert(any()) } - @Test - fun `insertChannels preserves a newer stored read for a read-events-disabled channel`() = runTest { + @ParameterizedTest(name = "{0}") + @MethodSource("currentUserReadMergeInput") + fun `insertChannels merges the incoming current user read according to the channel config`( + testName: String, + readEventsEnabled: Boolean, + storedEventDate: Date, + incomingEventDate: Date, + expectedUnreadMessages: Int, + ) = runTest { val repo = repositoryWithUserEchoingIds() val cid = "messaging:local" - // Stored on disk: a locally-tracked read with a recent event date and a non-zero count. val stored = randomChannel( id = "local", type = "messaging", - config = randomConfig(readEventsEnabled = false), + config = randomConfig(readEventsEnabled = readEventsEnabled), read = listOf( - randomChannelUserRead(user = currentUser, unreadMessages = 5, lastReceivedEventDate = Date(2000)), + randomChannelUserRead(user = currentUser, unreadMessages = 5, lastReceivedEventDate = storedEventDate), ), ) whenever(channelDao.select(cid)) doReturn stored.toEntity() - // Server sends a stale read with count 0. - val serverPayload = randomChannel( - id = "local", - type = "messaging", - config = randomConfig(readEventsEnabled = false), + // The server sends a read with unreadMessages = 0 + val serverPayload = stored.copy( read = listOf( - randomChannelUserRead(user = currentUser, unreadMessages = 0, lastReceivedEventDate = Date(1000)), + randomChannelUserRead(user = currentUser, unreadMessages = 0, lastReceivedEventDate = incomingEventDate), ), ) repo.insertChannels(listOf(serverPayload)) - assertEquals(5, persistedUnreadCount(cid)) - } - - @Test - fun `insertChannels lets the server read win for a read-events-enabled channel`() = runTest { - val repo = repositoryWithUserEchoingIds() - val cid = "messaging:regular" - val stored = randomChannel( - id = "regular", - type = "messaging", - config = randomConfig(readEventsEnabled = true), - read = listOf( - randomChannelUserRead(user = currentUser, unreadMessages = 5, lastReceivedEventDate = Date(2000)), - ), - ) - whenever(channelDao.select(cid)) doReturn stored.toEntity() - val serverPayload = randomChannel( - id = "regular", - type = "messaging", - config = randomConfig(readEventsEnabled = true), - read = listOf( - randomChannelUserRead(user = currentUser, unreadMessages = 0, lastReceivedEventDate = Date(1000)), - ), - ) - - repo.insertChannels(listOf(serverPayload)) - - assertEquals(0, persistedUnreadCount(cid)) - } - - @Test - fun `insertChannels preserves the stored current user read on tying event dates for a read-events-disabled channel`() = - runTest { - val repo = repositoryWithUserEchoingIds() - val cid = "messaging:local" - // Server reads carry lastReceivedEventDate = last_message_at, which ties with the locally - // tracked value anchored to the same message - a recency merge would let the stale server - // count win here. - val lastMessageDate = Date(2000) - val stored = randomChannel( - id = "local", - type = "messaging", - config = randomConfig(readEventsEnabled = false), - read = listOf( - randomChannelUserRead( - user = currentUser, - unreadMessages = 5, - lastReceivedEventDate = lastMessageDate, - ), - ), - ) - whenever(channelDao.select(cid)) doReturn stored.toEntity() - val serverPayload = randomChannel( - id = "local", - type = "messaging", - config = randomConfig(readEventsEnabled = false), - read = listOf( - randomChannelUserRead( - user = currentUser, - unreadMessages = 0, - lastReceivedEventDate = lastMessageDate, - ), - ), - ) - - repo.insertChannels(listOf(serverPayload)) - - assertEquals(5, persistedUnreadCount(cid)) - } - - @Test - fun `insertChannels preserves the stored current user read even when the server read is more recent`() = runTest { - val repo = repositoryWithUserEchoingIds() - val cid = "messaging:local" - val stored = randomChannel( - id = "local", - type = "messaging", - config = randomConfig(readEventsEnabled = false), - read = listOf( - randomChannelUserRead(user = currentUser, unreadMessages = 5, lastReceivedEventDate = Date(1000)), - ), - ) - whenever(channelDao.select(cid)) doReturn stored.toEntity() - val serverPayload = randomChannel( - id = "local", - type = "messaging", - config = randomConfig(readEventsEnabled = false), - read = listOf( - randomChannelUserRead(user = currentUser, unreadMessages = 0, lastReceivedEventDate = Date(2000)), - ), - ) - - repo.insertChannels(listOf(serverPayload)) - - assertEquals(5, persistedUnreadCount(cid)) + assertEquals(expectedUnreadMessages, persistedUnreadCount(cid)) } @Test @@ -308,4 +220,18 @@ internal class ChannelRepositoryImplTest { .firstOrNull { it.cid == cid } ?.reads?.get(currentUser.id)?.unreadMessages } + + companion object { + + @JvmStatic + fun currentUserReadMergeInput() = listOf( + // (test name, readEventsEnabled, storedEventDate, incomingEventDate, expectedUnreadMessages) + // Read events disabled: the read is tracked locally and the stored value always wins + Arguments.of("read events disabled, older incoming read", false, Date(2000), Date(1000), 5), + Arguments.of("read events disabled, tying incoming read", false, Date(2000), Date(2000), 5), + Arguments.of("read events disabled, newer incoming read", false, Date(1000), Date(2000), 5), + // Read events enabled: the incoming read wins + Arguments.of("read events enabled, older incoming read", true, Date(2000), Date(1000), 0), + ) + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt index b019f3de8d2..1f9f0677308 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalUnreadCountTest.kt @@ -25,6 +25,9 @@ import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import java.util.Date /** @@ -37,7 +40,6 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() fun `markRead resets unread count locally when local tracking is enabled and read events are disabled`() = runTest { val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) state.setMessages(listOf(createMessage(1, timestamp = 5000))) state.updateRead(createRead(currentUser, unreadMessages = 5, lastRead = Date(1000))) // when @@ -50,7 +52,6 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() @Test fun `markRead is ignored when local tracking is disabled and read events are disabled`() = runTest { val state = localTrackingState(isLocalUnreadCountEnabled = false) - state.setChannelConfig(Config(readEventsEnabled = false)) state.setMessages(listOf(createMessage(1, timestamp = 5000))) state.updateRead(createRead(currentUser, unreadMessages = 5, lastRead = Date(1000))) // when @@ -62,8 +63,7 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() @Test fun `markRead uses the remote path when read events are enabled even if local tracking is enabled`() = runTest { - val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = true)) + val state = localTrackingState(readEventsEnabled = true) state.setMessages(listOf(createMessage(1, timestamp = 5000))) state.updateRead( createRead(currentUser, unreadMessages = 5, lastRead = Date(1000), lastReadMessageId = "old_id"), @@ -79,7 +79,6 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() fun `updateCurrentUserRead creates a read state and increments when none exists for a local tracking channel`() = runTest { val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) assertNull(state.read.value) val message = createMessage(1, user = randomUser(id = "other_user")) // when @@ -91,7 +90,6 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() @Test fun `updateCurrentUserRead does not create a read state for own messages on a local tracking channel`() = runTest { val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) val message = createMessage(1, user = currentUser) // when state.updateCurrentUserRead(Date(2000), message) @@ -103,7 +101,6 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() @Test fun `updateCurrentUserRead does not create a read state when local tracking is disabled`() = runTest { val state = localTrackingState(isLocalUnreadCountEnabled = false) - state.setChannelConfig(Config(readEventsEnabled = false)) val message = createMessage(1, user = randomUser(id = "other_user")) // when state.updateCurrentUserRead(Date(2000), message) @@ -111,10 +108,21 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() assertNull(state.read.value) } + @Test + fun `updateCurrentUserRead does not create a read state when read events are enabled`() = runTest { + // read events enabled server-side, so the server owns the read state + val state = localTrackingState(readEventsEnabled = true) + val message = createMessage(1, user = randomUser(id = "other_user")) + // when + state.updateCurrentUserRead(Date(2000), message) + // then + assertNull(state.read.value) + assertEquals(0, state.unreadCount.value) + } + @Test fun `local tracking counts only messages received after a mark read`() = runTest { val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) val otherUser = randomUser(id = "other_user") // first message arrives -> unread = 1 (the read is updated before the message is upserted, // mirroring the event handling order) @@ -138,18 +146,11 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() // the newest message already counted (and persisted) before the restart. That message is // already part of the state seeded from the database, so it must not be counted twice. val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) - val otherUser = randomUser(id = "other_user") - val message = createMessage(1, timestamp = 5000, user = otherUser) + val message = createMessage(1, timestamp = 5000, user = randomUser(id = "other_user")) // Seeded from the database: the message and the read counting it state.setMessages(listOf(message)) state.updateRead( - createRead( - user = currentUser, - unreadMessages = 1, - lastRead = Date(1000), - lastReceivedEventDate = Date(5000), - ), + createRead(currentUser, unreadMessages = 1, lastRead = Date(1000), lastReceivedEventDate = Date(5000)), ) // when: the sync replays the message event state.updateCurrentUserRead(Date(5000), message) @@ -157,98 +158,65 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() assertEquals(1, state.unreadCount.value) } - @Test - fun `updateCurrentUserRead does not create a read state when read events are enabled`() = runTest { - // read events enabled server-side, so the server owns the read state - val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = true)) - val message = createMessage(1, user = randomUser(id = "other_user")) - // when - state.updateCurrentUserRead(Date(2000), message) - // then - assertNull(state.read.value) - assertEquals(0, state.unreadCount.value) - } - - @Test - fun `updateReads preserves the locally tracked read even when the server data is more recent`() = runTest { - val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) + @ParameterizedTest(name = "{0}") + @MethodSource("updateReadsMergeInput") + fun `updateReads merges the server read according to the local tracking state`( + testName: String, + isLocalUnreadCountEnabled: Boolean, + readEventsEnabled: Boolean, + serverEventDate: Date, + expectedUnreadMessages: Int, + ) = runTest { + val state = localTrackingState(isLocalUnreadCountEnabled, readEventsEnabled) state.updateRead( - createRead(currentUser, unreadMessages = 3, lastRead = Date(1000)), - ) - // when: the server sends a sync payload with unreadMessages = 0 and a newer event date - val serverRead = createRead( - user = currentUser, - unreadMessages = 0, - lastRead = Date(5000), - lastReceivedEventDate = Date(5000), - lastReadMessageId = "server_message_id", + createRead(currentUser, unreadMessages = 3, lastRead = Date(1000), lastReceivedEventDate = Date(3000)), ) - state.updateReads(listOf(serverRead)) - // then: the locally tracked read state must be preserved - val read = state.read.value - assertEquals(3, read?.unreadMessages) - assertEquals(Date(1000), read?.lastRead) - assertNull(read?.lastReadMessageId) - } - - @Test - fun `updateReads preserves the locally tracked read on equal event dates`() = runTest { - // Server reads carry lastReceivedEventDate = last_message_at, which ties with the local value - // anchored to the same message - a recency merge would let the stale server count win here. - val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) - val lastMessageDate = Date(3000) - state.updateRead( - createRead( - user = currentUser, - unreadMessages = 3, - lastRead = Date(1000), - lastReceivedEventDate = lastMessageDate, + // when: the server sends a read with unreadMessages = 0 + state.updateReads( + listOf( + createRead( + user = currentUser, + unreadMessages = 0, + lastRead = Date(1000), + lastReceivedEventDate = serverEventDate, + ), ), ) - // when: the server read ties on lastReceivedEventDate and carries a stale count - val serverRead = createRead( - user = currentUser, - unreadMessages = 0, - lastRead = Date(1000), - lastReceivedEventDate = lastMessageDate, - ) - state.updateReads(listOf(serverRead)) // then - assertEquals(3, state.read.value?.unreadMessages) + assertEquals(expectedUnreadMessages, state.read.value?.unreadMessages) } @Test - fun `updateReads merges delivered fields from the server for locally tracked reads`() = runTest { - val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) - state.updateRead( - createRead(currentUser, unreadMessages = 3, lastRead = Date(1000)), - ) - // when - val serverRead = createRead( - user = currentUser.copy(name = "Updated Name"), - unreadMessages = 0, - lastRead = Date(5000), - lastDeliveredAt = Date(4000), - lastDeliveredMessageId = "delivered_message_id", - ) - state.updateReads(listOf(serverRead)) - // then: user info and delivered fields come from the server, the rest stays local - val read = state.read.value - assertEquals(3, read?.unreadMessages) - assertEquals("Updated Name", read?.user?.name) - assertEquals(Date(4000), read?.lastDeliveredAt) - assertEquals("delivered_message_id", read?.lastDeliveredMessageId) - } + fun `updateReads merges only user info and delivered fields from the server for locally tracked reads`() = + runTest { + val state = localTrackingState() + state.updateRead( + createRead(currentUser, unreadMessages = 3, lastRead = Date(1000)), + ) + // when + val serverRead = createRead( + user = currentUser.copy(name = "Updated Name"), + unreadMessages = 0, + lastRead = Date(5000), + lastReadMessageId = "server_message_id", + lastDeliveredAt = Date(4000), + lastDeliveredMessageId = "delivered_message_id", + ) + state.updateReads(listOf(serverRead)) + // then + val read = state.read.value + assertEquals(3, read?.unreadMessages) + assertEquals(Date(1000), read?.lastRead) + assertNull(read?.lastReadMessageId) + assertEquals("Updated Name", read?.user?.name) + assertEquals(Date(4000), read?.lastDeliveredAt) + assertEquals("delivered_message_id", read?.lastDeliveredMessageId) + } @Test fun `updateReads uses the server data when no local read exists for a locally tracked channel`() = runTest { // first channel load, no local read state yet val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) assertNull(state.read.value) // when state.updateReads( @@ -258,50 +226,9 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() assertEquals(5, state.read.value?.unreadMessages) } - @Test - fun `updateReads uses more recent server data when read events are enabled`() = runTest { - // even with the flag on, channels with read events enabled follow the server - val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = true)) - state.updateRead( - createRead(currentUser, unreadMessages = 9, lastRead = Date(1000)), - ) - // when - val serverRead = createRead( - user = currentUser, - unreadMessages = 1, - lastRead = Date(5000), - lastReceivedEventDate = Date(5000), - ) - state.updateReads(listOf(serverRead)) - // then - assertEquals(1, state.read.value?.unreadMessages) - } - - @Test - fun `updateReads uses more recent server data when local tracking is disabled`() = runTest { - // flag off, the standard recency merge applies - val state = localTrackingState(isLocalUnreadCountEnabled = false) - state.setChannelConfig(Config(readEventsEnabled = false)) - state.updateRead( - createRead(currentUser, unreadMessages = 7, lastRead = Date(1000)), - ) - // when - val serverRead = createRead( - user = currentUser, - unreadMessages = 0, - lastRead = Date(5000), - lastReceivedEventDate = Date(5000), - ) - state.updateReads(listOf(serverRead)) - // then - assertEquals(0, state.read.value?.unreadMessages) - } - @Test fun `updateReads does not affect other users reads for locally tracked channels`() = runTest { val state = localTrackingState() - state.setChannelConfig(Config(readEventsEnabled = false)) val otherUser = randomUser(id = "other_user") // when state.updateReads( @@ -312,7 +239,10 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() assertEquals(10, otherRead?.unreadMessages) } - private fun localTrackingState(isLocalUnreadCountEnabled: Boolean = true) = ChannelStateImpl( + private fun localTrackingState( + isLocalUnreadCountEnabled: Boolean = true, + readEventsEnabled: Boolean = false, + ) = ChannelStateImpl( channelType = CHANNEL_TYPE, channelId = CHANNEL_ID, currentUser = userFlow, @@ -321,7 +251,9 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() liveLocations = MutableStateFlow(emptyList()), messageLimit = null, isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, - ) + ).apply { + setChannelConfig(Config(readEventsEnabled = readEventsEnabled)) + } private fun createRead( user: User, @@ -340,4 +272,21 @@ internal class ChannelStateImplLocalUnreadCountTest : ChannelStateImplTestBase() lastDeliveredAt = lastDeliveredAt, lastDeliveredMessageId = lastDeliveredMessageId, ) + + companion object { + + @JvmStatic + fun updateReadsMergeInput() = listOf( + // (test name, isLocalUnreadCountEnabled, readEventsEnabled, serverEventDate, expectedUnreadMessages) + // Locally tracked: preserved even when the server read is more recent + Arguments.of("locally tracked, newer server read", true, false, Date(5000), 3), + // Locally tracked: preserved on tying event dates (server reads carry + // lastReceivedEventDate = last_message_at, which ties the local value) + Arguments.of("locally tracked, tying server read", true, false, Date(3000), 3), + // Read events enabled: the more recent server read wins + Arguments.of("read events enabled, newer server read", true, true, Date(5000), 0), + // Local tracking disabled: the standard recency merge applies + Arguments.of("local tracking disabled, newer server read", false, false, Date(5000), 0), + ) + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt index 0998f35098e..8504b6def9c 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateLegacyImplLocalUnreadCountTest.kt @@ -40,7 +40,6 @@ internal class ChannelStateLegacyImplLocalUnreadCountTest { runTest { val state = localTrackingState() state.setMessages(listOf(randomMessage(parentId = null, shadowed = false))) - state.setChannelConfig(randomConfig(readEventsEnabled = false)) state.upsertReads(listOf(randomChannelUserRead(user = currentUser, unreadMessages = 3))) // when val result = state.markChannelAsRead() @@ -53,7 +52,6 @@ internal class ChannelStateLegacyImplLocalUnreadCountTest { fun `markChannelAsRead is ignored when local tracking is disabled and read events are disabled`() = runTest { val state = localTrackingState(isLocalUnreadCountEnabled = false) state.setMessages(listOf(randomMessage(parentId = null, shadowed = false))) - state.setChannelConfig(randomConfig(readEventsEnabled = false)) state.upsertReads(listOf(randomChannelUserRead(user = currentUser, unreadMessages = 3))) // when val result = state.markChannelAsRead() @@ -62,7 +60,10 @@ internal class ChannelStateLegacyImplLocalUnreadCountTest { assertEquals(3, state.read.value?.unreadMessages) } - private fun localTrackingState(isLocalUnreadCountEnabled: Boolean = true) = ChannelStateLegacyImpl( + private fun localTrackingState( + isLocalUnreadCountEnabled: Boolean = true, + readEventsEnabled: Boolean = false, + ) = ChannelStateLegacyImpl( channelType = CHANNEL_TYPE, channelId = CHANNEL_ID, userFlow = userFlow, @@ -71,7 +72,9 @@ internal class ChannelStateLegacyImplLocalUnreadCountTest { baseMessageLimit = null, isLocalUnreadCountEnabled = isLocalUnreadCountEnabled, now = ::currentTime, - ) + ).apply { + setChannelConfig(randomConfig(readEventsEnabled = readEventsEnabled)) + } private companion object { @JvmField From ec790012c60b42256a497e14b9961f73b6853514 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:10:53 +0200 Subject: [PATCH 15/21] client: Collect the channel ids to persist into a set --- .../event/handler/internal/EventHandlerSequential.kt | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index 533002bb396..9ce0c700a91 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -982,22 +982,20 @@ internal class EventHandlerSequential( private suspend fun persistLocallyTrackedReads(events: List) { if (!isLocalUnreadCountEnabled) return events - .mapNotNull { event -> + .mapNotNullTo(mutableSetOf()) { event -> when (event) { - is NewMessageEvent -> event.cid - is NotificationMessageNewEvent -> event.cid + is NewMessageEvent -> ChannelId.fromCid(event.cid) + is NotificationMessageNewEvent -> ChannelId.fromCid(event.cid) else -> null } } - .distinct() - .forEach { cid -> - val channelId = ChannelId.fromCid(cid) ?: return@forEach + .forEach { channelId -> if (!stateRegistry.isActiveChannel(channelId)) return@forEach val channelState = stateRegistry.channel(channelId) if (channelState.channelConfig.value.readEventsEnabled) return@forEach val stateReads = channelState.reads.value if (stateReads.isEmpty()) return@forEach - repos.upsertChannelReads(cid = cid, reads = stateReads) + repos.upsertChannelReads(cid = channelId.cid, reads = stateReads) } } From 11491a1aa8d2a924dd1309c6fdfd33be4bcbd111 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:26:02 +0200 Subject: [PATCH 16/21] client: Avoid intermediate collections in read merging --- .../domain/channel/internal/DatabaseChannelRepository.kt | 2 +- .../logic/querychannels/internal/QueryChannelsStateLogic.kt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt index 8c7ea6ef7d8..8f624684dbc 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt @@ -97,7 +97,7 @@ internal class DatabaseChannelRepository( ?: channelDao.select(cid)?.reads?.values?.map { it.toModel(getUser) } ?: return this if (storedReads.isEmpty()) return this - val mergedByUser = read.associateBy(ChannelUserRead::getUserId).toMutableMap() + val mergedByUser = read.associateByTo(mutableMapOf(), ChannelUserRead::getUserId) storedReads.forEach { stored -> val incoming = mergedByUser[stored.getUserId()] mergedByUser[stored.getUserId()] = when { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt index 7387716cee9..3f90cba223c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/logic/querychannels/internal/QueryChannelsStateLogic.kt @@ -190,8 +190,7 @@ internal class QueryChannelsStateLogic( // For read-events-disabled channels the locally tracked read is authoritative over the raw // payload set above, so re-derive those channels from the merged per-channel state. validated - .filter { (_, channel) -> !channel.config.readEventsEnabled } - .map { (id, _) -> id.cid } + .mapNotNull { (id, channel) -> id.cid.takeUnless { channel.config.readEventsEnabled } } .takeIf { it.isNotEmpty() } ?.let(::refreshChannels) } From eadbb8f91ceff205d3e16aa422d10f4935b9d093 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:30:45 +0200 Subject: [PATCH 17/21] client: Trim upsertChannelReads doc --- .../client/persistance/repository/ChannelRepository.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt index e287bec096b..32beba60106 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/ChannelRepository.kt @@ -157,10 +157,9 @@ public interface ChannelRepository { public suspend fun updateLastMessageForChannel(cid: String, lastMessage: Message) /** - * Upserts the given [reads] into the channel stored with the given [cid] (ie messaging:123), - * replacing the stored reads of the same users. Used by the local unread count tracking: unlike - * [insertChannel], the write is applied verbatim - the caller is the source of truth. - * The default no-op must be overridden for the locally tracked count to survive app restarts. + * Upserts the given [reads] into the channel stored with the given [cid], replacing the stored + * reads of the same users. The default no-op must be overridden for the locally tracked unread + * count to survive app restarts. */ public suspend fun upsertChannelReads(cid: String, reads: List) { // no-op by default From 6123d045d85eb35df3e54a7b0c393d071181c7d4 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:32:34 +0200 Subject: [PATCH 18/21] compose-sample: Drop the restart note from the local unread count flag --- .../src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index 373f765ce9f..a31921dcedd 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -46,7 +46,7 @@ Channel pinning Show the Pin/Unpin Chat action in the channel options menu Local unread count - Track the unread count locally for channels with read events disabled. Also shows livestream channels in the channel list. Applied on the next login or app restart + Track the unread count locally for channels with read events disabled. Also shows livestream channels in the channel list Pinned Messages From 31957ed6218597be04ff4f78e8dea7e3bdde7a6a Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 12:44:59 +0200 Subject: [PATCH 19/21] client: Simplify local unread count listener and merge tests --- .../ChannelMarkReadListenerStateTest.kt | 98 +++++++------------ .../internal/legacy/ChannelStateLogicTest.kt | 90 ++++++----------- 2 files changed, 63 insertions(+), 125 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt index 43b99b812f0..33485f201a9 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/ChannelMarkReadListenerStateTest.kt @@ -23,11 +23,13 @@ import io.getstream.chat.android.client.internal.state.plugin.state.channel.inte import io.getstream.result.Result import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertInstanceOf -import org.junit.jupiter.api.Test -import org.mockito.kotlin.any +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verify internal class ChannelMarkReadListenerStateTest { @@ -35,73 +37,28 @@ internal class ChannelMarkReadListenerStateTest { private val channelType = "messaging" private val channelId = "123" - @Test - fun `Remote result makes the precondition succeed so the network request proceeds`() = runTest { - val listener = listenerWith(markReadResult = MarkReadResult.RemoteRequired) - // when - val result = listener.onChannelMarkReadPrecondition(channelType, channelId) - // then - assertInstanceOf(Result.Success::class.java, result) - } - - @Test - fun `Local result makes the precondition fail so no network request follows`() = runTest { - val listener = listenerWith(markReadResult = MarkReadResult.HandledLocally) - // when - val result = listener.onChannelMarkReadPrecondition(channelType, channelId) - // then - assertInstanceOf(Result.Failure::class.java, result) - } - - @Test - fun `Local result refreshes the channel in the active channel-list queries`() = runTest { - // No server read event follows a local mark-read, so the listener must trigger the - // channel-list refresh that the event would otherwise cause. - val queryChannelsLogic: QueryChannelsLogic = mock() - val listener = listenerWith( - markReadResult = MarkReadResult.HandledLocally, - activeQueryChannelsLogic = listOf(queryChannelsLogic), - ) - // when - listener.onChannelMarkReadPrecondition(channelType, channelId) - // then - verify(queryChannelsLogic).refreshChannelState("$channelType:$channelId") - } - - @Test - fun `Remote result does not refresh the channel-list queries`() = runTest { - // The server read event triggers the refresh through the regular event handling. - val queryChannelsLogic: QueryChannelsLogic = mock() - val listener = listenerWith( - markReadResult = MarkReadResult.RemoteRequired, - activeQueryChannelsLogic = listOf(queryChannelsLogic), - ) - // when - listener.onChannelMarkReadPrecondition(channelType, channelId) - // then - verify(queryChannelsLogic, never()).refreshChannelState(any()) - } - - @Test - fun `None result does not refresh the channel-list queries`() = runTest { + @ParameterizedTest(name = "{0}") + @MethodSource("markReadResults") + fun `onChannelMarkReadPrecondition maps the mark-read result to a precondition and a channel-list refresh`( + testName: String, + markReadResult: MarkReadResult, + succeeds: Boolean, + refreshes: Boolean, + ) = runTest { + // A remote mark-read succeeds (the network request follows) and relies on the server read + // event for the refresh; a local one fails (no request) and must refresh the channel-list + // queries itself; a not-needed one fails and refreshes nothing. val queryChannelsLogic: QueryChannelsLogic = mock() - val listener = listenerWith( - markReadResult = MarkReadResult.NotNeeded, - activeQueryChannelsLogic = listOf(queryChannelsLogic), - ) - // when - listener.onChannelMarkReadPrecondition(channelType, channelId) - // then - verify(queryChannelsLogic, never()).refreshChannelState(any()) - } - - @Test - fun `None result makes the precondition fail`() = runTest { - val listener = listenerWith(markReadResult = MarkReadResult.NotNeeded) + val listener = listenerWith(markReadResult, listOf(queryChannelsLogic)) // when val result = listener.onChannelMarkReadPrecondition(channelType, channelId) // then - assertInstanceOf(Result.Failure::class.java, result) + assertInstanceOf( + if (succeeds) Result.Success::class.java else Result.Failure::class.java, + result, + ) + verify(queryChannelsLogic, if (refreshes) times(1) else never()) + .refreshChannelState("$channelType:$channelId") } private fun listenerWith( @@ -117,4 +74,15 @@ internal class ChannelMarkReadListenerStateTest { } return ChannelMarkReadListenerState(logic) } + + companion object { + + @JvmStatic + fun markReadResults() = listOf( + // (test name, markReadResult, succeeds, refreshes) + Arguments.of("remote required", MarkReadResult.RemoteRequired, true, false), + Arguments.of("handled locally", MarkReadResult.HandledLocally, false, true), + Arguments.of("not needed", MarkReadResult.NotNeeded, false, false), + ) + } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt index 41ac679eb05..dd708571763 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/logic/channel/internal/legacy/ChannelStateLogicTest.kt @@ -972,21 +972,13 @@ internal class ChannelStateLogicTest { @Test fun `Given locally tracked channel, When updateDataForChannel is called with more recent server read, Then local read state is preserved`() { - // given - local unread count enabled, read events disabled, locally incremented count - whenever(mutableState.isLocalUnreadCountEnabled) doReturn true - _channelConfig.value = Config(readEventsEnabled = false) - val localRead = ChannelUserRead( user = user, lastReceivedEventDate = Date(50L), unreadMessages = 3, lastRead = Date(40L), lastReadMessageId = null, - lastDeliveredAt = null, - lastDeliveredMessageId = null, ) - _read.value = localRead - // Server sends a sync payload with unreadMessages = 0 and a newer event date val serverRead = ChannelUserRead( user = user.copy(name = "Updated Name"), @@ -997,25 +989,15 @@ internal class ChannelStateLogicTest { lastDeliveredAt = Date(85L), lastDeliveredMessageId = "server-delivered-id", ) - val channel = randomChannel(read = listOf(serverRead)) - // when - channelStateLogic.updateDataForChannel( - channel = channel, - messageLimit = 0, - ) - - // then - the locally tracked read state is preserved; only user info and delivered fields - // are merged from the server - verify(mutableState).upsertReads( - eq( - listOf( - localRead.copy( - user = serverRead.user, - lastDeliveredAt = serverRead.lastDeliveredAt, - lastDeliveredMessageId = serverRead.lastDeliveredMessageId, - ), - ), + assertLocallyTrackedMerge( + localRead = localRead, + serverRead = serverRead, + // only user info and delivered fields are merged from the server + expectedRead = localRead.copy( + user = serverRead.user, + lastDeliveredAt = serverRead.lastDeliveredAt, + lastDeliveredMessageId = serverRead.lastDeliveredMessageId, ), ) } @@ -1024,9 +1006,6 @@ internal class ChannelStateLogicTest { fun `Given locally tracked channel, When updateDataForChannel is called with a server read tying on event date, Then local read state is preserved`() { // Server reads carry lastReceivedEventDate = last_message_at, which ties with the local value // anchored to the same message - a recency merge would let the stale server count win here. - whenever(mutableState.isLocalUnreadCountEnabled) doReturn true - _channelConfig.value = Config(readEventsEnabled = false) - val lastMessageDate = Date(100L) val localRead = ChannelUserRead( user = user, @@ -1035,34 +1014,14 @@ internal class ChannelStateLogicTest { lastRead = Date(40L), lastReadMessageId = null, ) - _read.value = localRead + val serverRead = localRead.copy(unreadMessages = 0) - val serverRead = ChannelUserRead( - user = user, - lastReceivedEventDate = lastMessageDate, - unreadMessages = 0, - lastRead = Date(40L), - lastReadMessageId = null, - ) - val channel = randomChannel(read = listOf(serverRead)) - - // when - channelStateLogic.updateDataForChannel( - channel = channel, - messageLimit = 0, - ) - - // then - verify(mutableState).upsertReads(eq(listOf(localRead))) + assertLocallyTrackedMerge(localRead = localRead, serverRead = serverRead, expectedRead = localRead) } @Test fun `Given locally tracked channel without local read, When updateDataForChannel is called, Then server data is used`() { - // given - first channel load, no local read state yet - whenever(mutableState.isLocalUnreadCountEnabled) doReturn true - _channelConfig.value = Config(readEventsEnabled = false) - _read.value = null - + // first channel load, no local read state yet - the server value is authoritative val serverRead = ChannelUserRead( user = user, lastReceivedEventDate = Date(100L), @@ -1070,16 +1029,27 @@ internal class ChannelStateLogicTest { lastRead = Date(95L), lastReadMessageId = "server-read-id", ) - val channel = randomChannel(read = listOf(serverRead)) - // when - channelStateLogic.updateDataForChannel( - channel = channel, - messageLimit = 0, - ) + assertLocallyTrackedMerge(localRead = null, serverRead = serverRead, expectedRead = serverRead) + } - // then - the server value is authoritative on first load - verify(mutableState).upsertReads(eq(listOf(serverRead))) + /** + * On a locally tracked channel (local unread count enabled, read events disabled), feeds + * [serverRead] through `updateDataForChannel` over the given [localRead] and asserts the merge + * upserts [expectedRead]. + */ + private fun assertLocallyTrackedMerge( + localRead: ChannelUserRead?, + serverRead: ChannelUserRead, + expectedRead: ChannelUserRead, + ) { + whenever(mutableState.isLocalUnreadCountEnabled) doReturn true + _channelConfig.value = Config(readEventsEnabled = false) + _read.value = localRead + + channelStateLogic.updateDataForChannel(channel = randomChannel(read = listOf(serverRead)), messageLimit = 0) + + verify(mutableState).upsertReads(eq(listOf(expectedRead))) } @Test From d17410e35cb46f5c72a3a6fef5095a42429d5421 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:05:03 +0200 Subject: [PATCH 20/21] client: Hoist DB reads out of the cache lock, cover notification events --- .../internal/DatabaseChannelRepository.kt | 25 +++++++++++----- .../internal/EventHandlerSequentialTest.kt | 30 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt index 8f624684dbc..df6af9305bc 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/DatabaseChannelRepository.kt @@ -68,10 +68,15 @@ internal class DatabaseChannelRepository( */ override suspend fun insertChannels(channels: Collection) { if (channels.isEmpty()) return + val storedReadsByCid = channels + .filterNot { it.config.readEventsEnabled || channelCache[it.cid] != null } + .associate { it.cid to channelDao.select(it.cid)?.reads?.values?.map { read -> read.toModel(getUser) } } val channelsToInsert = cacheMutex.withLock { val updatedChannels = channels .map { channelCache[it.cid]?.let { cachedChannel -> it.combine(cachedChannel) } ?: it } - .map { if (it.config.readEventsEnabled) it else it.preserveLocallyTrackedReads() } + .map { + if (it.config.readEventsEnabled) it else it.preserveLocallyTrackedReads(storedReadsByCid[it.cid]) + } updatedChannels .filter { channelCache[it.cid] != it } .also { cacheChannel(updatedChannels) } @@ -91,11 +96,12 @@ internal class DatabaseChannelRepository( * Keeps the stored current user read of read-events-disabled channels: it is tracked on-device and * must never be overwritten by server data (a recency check is not enough - server reads carry * lastReceivedEventDate = last_message_at, tying it). Other users' reads are merged by recency. + * + * [preloadedStoredReads] is the persisted read state fetched before acquiring the lock; the cache + * still takes priority when populated. */ - private suspend fun Channel.preserveLocallyTrackedReads(): Channel { - val storedReads = channelCache[cid]?.read - ?: channelDao.select(cid)?.reads?.values?.map { it.toModel(getUser) } - ?: return this + private fun Channel.preserveLocallyTrackedReads(preloadedStoredReads: List?): Channel { + val storedReads = channelCache[cid]?.read ?: preloadedStoredReads ?: return this if (storedReads.isEmpty()) return this val mergedByUser = read.associateByTo(mutableMapOf(), ChannelUserRead::getUserId) storedReads.forEach { stored -> @@ -122,10 +128,13 @@ internal class DatabaseChannelRepository( */ override suspend fun upsertChannelReads(cid: String, reads: List) { if (reads.isEmpty()) return + // Resolve the stored channel before the lock so no DB read runs while cacheMutex is held. + val stored = selectChannel(cid) ?: return val updatedChannel = cacheMutex.withLock { - val stored = selectChannel(cid) ?: return - stored - .copy(read = (reads + stored.read).distinctBy(ChannelUserRead::getUserId)) + // The cache takes priority in case it was updated between the read above and the lock. + val base = channelCache[cid] ?: stored + base + .copy(read = (reads + base.read).distinctBy(ChannelUserRead::getUserId)) .syncUnreadCountWithReads() .also { cacheChannel(it) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt index 5f23dd62d8f..7eab720c4c5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -471,6 +471,36 @@ internal class EventHandlerSequentialTest { verify(repos, never()).upsertChannelReads(any(), any()) } + @Test + fun `When local unread tracking is enabled, a notification new message event persists the locally tracked reads`() = + runTest { + val channelType = "livestream" + val channelId = "local-unread" + val cid = "$channelType:$channelId" + val repos: RepositoryFacade = mock() + repos.stub { + onBlocking { selectChannel(cid) } doReturn + randomChannel(id = channelId, type = channelType, ownCapabilities = emptySet()) + onBlocking { selectChannels(any()) } doReturn emptyList() + onBlocking { selectMessages(any()) } doReturn emptyList() + onBlocking { selectThreads(any()) } doReturn emptyList() + } + val localReads = listOf( + io.getstream.chat.android.randomChannelUserRead(user = currentUser, unreadMessages = 3), + ) + val handler = Fixture() + .withRepositoryFacade(repos) + .withLocalUnreadCountEnabled() + .withLocallyTrackedChannel(channelType, channelId, localReads) + .get(this) + + handler.handleEvents( + randomNotificationMessageNewEvent(cid = cid, channelType = channelType, channelId = channelId), + ) + + verify(repos).upsertChannelReads(cid, localReads) + } + @Test fun `When buffer overflows with DROP_OLDEST, the oldest queued NewMessageEvent is dropped`() = runTest { val fixture = Fixture() From e77e9879ecc4f7b1f86046e071f94bb3d0c28f2b Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:06:45 +0200 Subject: [PATCH 21/21] compose-sample: Extract the shared channel list factory helper --- .../feature/channel/list/ChannelsActivity.kt | 34 +---------- .../list/SampleChannelListViewModelFactory.kt | 59 +++++++++++++++++++ .../compose/sample/ui/chats/ChatsActivity.kt | 36 +---------- 3 files changed, 62 insertions(+), 67 deletions(-) create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/SampleChannelListViewModelFactory.kt diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt index b0629752a40..0db23e4b61b 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/ChannelsActivity.kt @@ -87,13 +87,10 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.theme.ChatUiConfig import io.getstream.chat.android.compose.ui.threads.ThreadsScreen import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModel -import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory import io.getstream.chat.android.compose.viewmodel.threads.ThreadsViewModelFactory import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.Thread -import io.getstream.chat.android.models.querysort.QuerySortByField import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.GlobalScope @@ -106,37 +103,8 @@ class ChannelsActivity : ComponentActivity() { private val settings by lazy { customSettings() } - /** - * When the local unread count is enabled, an explicit filter including livestream channels is used, - * to make the feature testable. Otherwise, the predefined server-side filter is used, which resolves - * to: messaging channels the current user is a member of, without a draft, sorted by "pinned_at" and - * "last_updated" descending. - */ private val channelsViewModelFactory by lazy { - val chatClient = ChatClient.instance() - val currentUserId = chatClient.getCurrentUser()?.id ?: "" - if (settings.isLocalUnreadCountEnabled) { - ChannelListViewModelFactory( - chatClient = chatClient, - querySort = QuerySortByField().desc("pinned_at").desc("last_updated"), - filters = Filters.and( - Filters.`in`("type", listOf("messaging", "livestream")), - Filters.`in`("members", listOf(currentUserId)), - Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), - ), - chatEventHandlerFactory = CustomChatEventHandlerFactory(), - ) - } else { - ChannelListViewModelFactory( - chatClient = chatClient, - predefinedFilterName = "android_sample_filter", - filterValues = mapOf( - "channel_type" to "messaging", - "user_id" to currentUserId, - ), - chatEventHandlerFactory = CustomChatEventHandlerFactory(), - ) - } + sampleChannelListViewModelFactory(settings) } private val channelsViewModel: ChannelListViewModel by viewModels { channelsViewModelFactory } diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/SampleChannelListViewModelFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/SampleChannelListViewModelFactory.kt new file mode 100644 index 00000000000..033847f30de --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/feature/channel/list/SampleChannelListViewModelFactory.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2014-2026 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.sample.feature.channel.list + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.compose.sample.data.CustomSettings +import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Filters +import io.getstream.chat.android.models.querysort.QuerySortByField + +/** + * Builds the channel list factory used by the sample. + * + * When the local unread count is enabled, an explicit filter including livestream channels is used, + * to make the feature testable. Otherwise, the predefined server-side filter is used, which resolves + * to: messaging channels the current user is a member of, without a draft, sorted by "pinned_at" and + * "last_updated" descending. + */ +fun sampleChannelListViewModelFactory(settings: CustomSettings): ChannelListViewModelFactory { + val chatClient = ChatClient.instance() + val currentUserId = chatClient.getCurrentUser()?.id.orEmpty() + return if (settings.isLocalUnreadCountEnabled) { + ChannelListViewModelFactory( + chatClient = chatClient, + querySort = QuerySortByField().desc("pinned_at").desc("last_updated"), + filters = Filters.and( + Filters.`in`("type", listOf("messaging", "livestream")), + Filters.`in`("members", listOf(currentUserId)), + Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), + ), + chatEventHandlerFactory = CustomChatEventHandlerFactory(), + ) + } else { + ChannelListViewModelFactory( + chatClient = chatClient, + predefinedFilterName = "android_sample_filter", + filterValues = mapOf( + "channel_type" to "messaging", + "user_id" to currentUserId, + ), + chatEventHandlerFactory = CustomChatEventHandlerFactory(), + ) + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt index 07b0b3967dc..c79296f6c75 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/chats/ChatsActivity.kt @@ -53,7 +53,7 @@ import io.getstream.chat.android.compose.sample.R import io.getstream.chat.android.compose.sample.data.customSettings import io.getstream.chat.android.compose.sample.feature.channel.add.AddChannelActivity import io.getstream.chat.android.compose.sample.feature.channel.isGroupChannel -import io.getstream.chat.android.compose.sample.feature.channel.list.CustomChatEventHandlerFactory +import io.getstream.chat.android.compose.sample.feature.channel.list.sampleChannelListViewModelFactory import io.getstream.chat.android.compose.sample.ui.SampleChatTheme import io.getstream.chat.android.compose.sample.ui.channel.MemberRolesTrailingContent import io.getstream.chat.android.compose.sample.ui.component.AppBottomBar @@ -86,16 +86,13 @@ import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsVie import io.getstream.chat.android.compose.viewmodel.channel.ChannelAttachmentsViewModelFactory import io.getstream.chat.android.compose.viewmodel.channel.ChannelInfoViewModel import io.getstream.chat.android.compose.viewmodel.channel.ChannelInfoViewModelFactory -import io.getstream.chat.android.compose.viewmodel.channels.ChannelListViewModelFactory import io.getstream.chat.android.compose.viewmodel.messages.ChannelViewModelFactory import io.getstream.chat.android.compose.viewmodel.messages.ComposerOptions import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewModel import io.getstream.chat.android.compose.viewmodel.pinned.PinnedMessageListViewModelFactory import io.getstream.chat.android.models.AttachmentType import io.getstream.chat.android.models.Channel -import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.ui.common.feature.channel.attachments.ChannelAttachmentsViewEvent import io.getstream.chat.android.ui.common.feature.channel.info.ChannelInfoViewEvent import io.getstream.chat.android.ui.common.state.channel.info.ChannelInfoViewState @@ -134,37 +131,8 @@ class ChatsActivity : ComponentActivity() { private val settings by lazy { customSettings() } - /** - * When the local unread count is enabled, an explicit filter including livestream channels is used, - * to make the feature testable. Otherwise, the predefined server-side filter is used, which resolves - * to: messaging channels the current user is a member of, without a draft, sorted by "pinned_at" and - * "last_updated" descending. - */ private val channelListViewModelFactory by lazy { - val chatClient = ChatClient.instance() - val currentUserId = chatClient.getCurrentUser()?.id ?: "" - if (settings.isLocalUnreadCountEnabled) { - ChannelListViewModelFactory( - chatClient = chatClient, - querySort = QuerySortByField().desc("pinned_at").desc("last_updated"), - filters = Filters.and( - Filters.`in`("type", listOf("messaging", "livestream")), - Filters.`in`("members", listOf(currentUserId)), - Filters.or(Filters.notExists("draft"), Filters.eq("draft", false)), - ), - chatEventHandlerFactory = CustomChatEventHandlerFactory(), - ) - } else { - ChannelListViewModelFactory( - chatClient = chatClient, - predefinedFilterName = "android_sample_filter", - filterValues = mapOf( - "channel_type" to "messaging", - "user_id" to currentUserId, - ), - chatEventHandlerFactory = CustomChatEventHandlerFactory(), - ) - } + sampleChannelListViewModelFactory(settings) } private val channelViewModelFactory by lazy {