From c18bbd229cf655a44bad705301a22383c45adcd9 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Tue, 10 Mar 2026 12:07:54 +0100 Subject: [PATCH 01/22] Fix scroll jump when returning to a channel after WS reconnect Co-Authored-By: Claude --- .../channel/internal/ChannelLogicImpl.kt | 46 +++++++++- .../channel/internal/ChannelStateImpl.kt | 20 ++++ .../channel/internal/ChannelLogicImplTest.kt | 91 +++++++++++++++++++ .../internal/ChannelStateImplMessagesTest.kt | 50 ++++++++++ 4 files changed, 203 insertions(+), 4 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 d3340d5c02d..8ded7564ae0 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 @@ -24,7 +24,9 @@ import io.getstream.chat.android.client.channel.ChannelMessagesUpdateLogic import io.getstream.chat.android.client.errors.isPermanent import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.getCreatedAtOrNull +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 @@ -91,8 +93,8 @@ internal class ChannelLogicImpl( updateDataForChannel( channel = channel, messageLimit = query.messagesLimit(), + shouldRefreshMessages = true, // Note: The following arguments are NOT used. But they are kept for backwards compatibility. - shouldRefreshMessages = query.shouldRefresh, scrollUpdate = false, isNotificationUpdate = query.isNotificationUpdate, isChannelsStateUpdate = true, @@ -302,13 +304,39 @@ internal class ChannelLogicImpl( state.setChannelConfig(channel.config) // Set pending messages state.setPendingMessages(channel.pendingMessages.map(PendingMessage::message)) - // Reset messages (ensure they are sorted - when coming from DB) + // Update messages based on the relationship between the incoming page and existing state. if (messageLimit > 0) { val sortedMessages = withContext(Dispatchers.Default) { channel.messages.sortedBy { it.getCreatedAtOrNull() } } - state.setMessages(sortedMessages) - state.setEndOfOlderMessages(channel.messages.size < messageLimit) + val currentMessages = state.messages.value + when { + shouldRefreshMessages || currentMessages.isEmpty() -> { + // Initial load (DB seed or first fetch) or explicit refresh — full replace + state.setMessages(sortedMessages) + state.setEndOfOlderMessages(channel.messages.size < messageLimit) + } + state.insideSearch.value -> { + // User's window was already trimmed away from the latest (insideSearch set by + // trimNewestMessages, or a prior jump-to-message). Stay at current position; + // refresh the "jump to latest" cache with the server's current latest page. + state.upsertCachedLatestMessages(sortedMessages) + } + hasGap(currentMessages, sortedMessages) -> { + // Incoming page is newer than the current window with no overlap. Inserting the + // incoming messages would create a fragmented list. Instead, treat the user's + // position as a mid-page: store the incoming as the "latest" cache and signal the UI. + state.upsertCachedLatestMessages(sortedMessages) + state.setInsideSearch(true) + state.setEndOfNewerMessages(false) + } + else -> { + // Incoming messages are contiguous with (or overlap) the current window. + // Upsert preserves the user's scroll position while adding/updating messages. + state.upsertMessages(sortedMessages) + state.setEndOfOlderMessages(channel.messages.size < messageLimit) + } + } } // Add pinned messages state.addPinnedMessages(channel.pinnedMessages) @@ -428,4 +456,14 @@ internal class ChannelLogicImpl( // Enrich the channel with messages return channel.copy(messages = messages) } + + private fun hasGap(currentMessages: List, incomingMessages: List): Boolean { + val currentNewest = currentMessages.maxByOrNull { it.getCreatedAtOrDefault(NEVER) } + val incomingOldest = incomingMessages.firstOrNull() + return currentMessages.isNotEmpty() && + currentNewest != null && + incomingOldest != null && + currentMessages.none { it.id == incomingOldest.id } && + incomingOldest.getCreatedAtOrDefault(NEVER).after(currentNewest.getCreatedAtOrDefault(NEVER)) + } } 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 25cc4bca93a..06eadae42d6 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 @@ -1411,6 +1411,26 @@ internal class ChannelStateImpl( _cachedLatestMessages.value = emptyList() } + /** + * Merges [messages] into the cached latest messages, replacing any existing entry + * with the same id and capping the list at [CACHED_LATEST_MESSAGES_LIMIT]. + * + * Called during reconnection to refresh the "jump to latest" cache with the server's + * current latest page without disturbing the user's active scroll position. + */ + fun upsertCachedLatestMessages(messages: List) { + if (messages.isEmpty()) return + val messagesToUpsert = messages.filterNot { shouldIgnoreUpsertion(it) } + if (messagesToUpsert.isEmpty()) return + _cachedLatestMessages.update { current -> + current.mergeSorted( + other = messagesToUpsert, + idSelector = Message::id, + comparator = MESSAGE_COMPARATOR, + ).takeLast(CACHED_LATEST_MESSAGES_LIMIT) + } + } + // endregion // region Destroy 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 3951b1cc1a2..1dcf83180b6 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 @@ -1384,6 +1384,97 @@ internal class ChannelLogicImplTest { // Then verify(stateImpl).setPendingMessages(listOf(pendingMsg)) } + + @Test + fun `should upsert messages when state has messages and incoming are contiguous`() = runTest { + val existingMsg = randomMessage(id = "existing", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(500L), createdLocallyAt = null) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = listOf(incomingMsg), + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + sut.updateDataForChannel(channel = channel, messageLimit = 30) + verify(stateImpl).upsertMessages(listOf(incomingMsg)) + verify(stateImpl, never()).setMessages(any()) + verify(stateImpl, never()).upsertCachedLatestMessages(any()) + verify(stateImpl, never()).setEndOfNewerMessages(any()) + } + + @Test + fun `should cache incoming and signal newer messages when gap is detected`() = runTest { + val existingMsg = randomMessage(id = "old", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = listOf(incomingMsg), + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + sut.updateDataForChannel(channel = channel, messageLimit = 30) + verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg)) + verify(stateImpl).setInsideSearch(true) + verify(stateImpl).setEndOfNewerMessages(false) + verify(stateImpl, never()).setMessages(any()) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).setEndOfOlderMessages(any()) + } + + @Test + fun `should refresh cached latest messages when already inside search`() = runTest { + val existingMsg = randomMessage(id = "mid", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(true)) + val incomingMsg = randomMessage(id = "latest", createdAt = Date(5000L), createdLocallyAt = null) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = listOf(incomingMsg), + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + sut.updateDataForChannel(channel = channel, messageLimit = 30) + verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg)) + verify(stateImpl, never()).setMessages(any()) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).setInsideSearch(any()) + verify(stateImpl, never()).setEndOfNewerMessages(any()) + } + + @Test + fun `should replace messages when shouldRefreshMessages is true regardless of existing state`() = runTest { + val existingMsg = randomMessage(id = "old", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = listOf(incomingMsg), + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + sut.updateDataForChannel(channel = channel, messageLimit = 30, shouldRefreshMessages = true) + verify(stateImpl).setMessages(listOf(incomingMsg)) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).upsertCachedLatestMessages(any()) + } } // endregion diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt index 93304d094d7..20c354372e5 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt @@ -601,6 +601,56 @@ internal class ChannelStateImplMessagesTest { } } + @Nested + inner class UpsertCachedLatestMessages { + + @Test + fun `upsertCachedLatestMessages with empty list should not change cache`() = runTest { + // given + val messages = createMessages(3) + channelState.setMessages(messages) + channelState.cacheLatestMessages() + channelState.setMessages(emptyList()) + val before = channelState.toChannel().cachedLatestMessages + // when + channelState.upsertCachedLatestMessages(emptyList()) + // then + assertEquals(before, channelState.toChannel().cachedLatestMessages) + } + + @Test + fun `upsertCachedLatestMessages with all filtered messages should not change cache`() = runTest { + // given + val regularMsg = createMessage(1, timestamp = 1000) + channelState.setMessages(listOf(regularMsg)) + channelState.cacheLatestMessages() + channelState.setMessages(emptyList()) + val before = channelState.toChannel().cachedLatestMessages + // when — thread reply not shown in channel is always filtered out + val threadReply = createMessage(2, parentId = "parent1", showInChannel = false) + channelState.upsertCachedLatestMessages(listOf(threadReply)) + // then + assertEquals(before, channelState.toChannel().cachedLatestMessages) + } + + @Test + fun `upsertCachedLatestMessages should merge incoming messages into the cache`() = runTest { + // given + val msg1 = createMessage(1, timestamp = 1000) + val msg5 = createMessage(5, timestamp = 5000) + channelState.setMessages(listOf(msg1, msg5)) + channelState.cacheLatestMessages() + channelState.setMessages(emptyList()) + // when + val msg3 = createMessage(3, timestamp = 3000) + channelState.upsertCachedLatestMessages(listOf(msg3)) + // then + val cachedMessages = channelState.toChannel().cachedLatestMessages + assertEquals(3, cachedMessages.size) + assertEquals(listOf("message_1", "message_3", "message_5"), cachedMessages.map { it.id }) + } + } + @Nested inner class GetMessageById { From 408babcb46adc075a9a9407e86519a39c7dd7393 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 12 Mar 2026 20:54:43 +0100 Subject: [PATCH 02/22] test(01-01): add failing stub tests for Message.isLocalOnly() predicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 8 @Disabled @Test stubs covering all SyncStatus values (SYNC_NEEDED, IN_PROGRESS, AWAITING_ATTACHMENTS, FAILED_PERMANENTLY) plus type-based cases (ephemeral/error with COMPLETED) and negative cases (COMPLETED/system) - All stubs disabled with "Wave 1 — implement isLocalOnly() first" message - Satisfies PRES-05 Wave 0 requirement: test class resolvable by Gradle --tests "*.MessageIsLocalOnlyTest" without "no tests found" failure --- .../internal/MessageIsLocalOnlyTest.kt | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt new file mode 100644 index 00000000000..324737dbfcf --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt @@ -0,0 +1,80 @@ +/* + * 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.MessageType +import io.getstream.chat.android.models.SyncStatus +import io.getstream.chat.android.randomMessage +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test + +/** + * Stub tests for the [Message.isLocalOnly] predicate. + * All tests are @Disabled — they will be enabled in Wave 1 once isLocalOnly() is implemented. + * + * Requirements covered: PRES-05 + */ +internal class MessageIsLocalOnlyTest { + + @Test + @Disabled("Wave 1 — implement isLocalOnly() first") + fun `isLocalOnly returns true for SyncStatus SYNC_NEEDED`() { + TODO("Implement after Wave 1") + } + + @Test + @Disabled("Wave 1 — implement isLocalOnly() first") + fun `isLocalOnly returns true for SyncStatus IN_PROGRESS`() { + TODO("Implement after Wave 1") + } + + @Test + @Disabled("Wave 1 — implement isLocalOnly() first") + fun `isLocalOnly returns true for SyncStatus AWAITING_ATTACHMENTS`() { + TODO("Implement after Wave 1") + } + + @Test + @Disabled("Wave 1 — implement isLocalOnly() first") + fun `isLocalOnly returns true for SyncStatus FAILED_PERMANENTLY`() { + TODO("Implement after Wave 1") + } + + @Test + @Disabled("Wave 1 — implement isLocalOnly() first") + fun `isLocalOnly returns true for type ephemeral with COMPLETED syncStatus`() { + TODO("Implement after Wave 1") + } + + @Test + @Disabled("Wave 1 — implement isLocalOnly() first") + fun `isLocalOnly returns true for type error with COMPLETED syncStatus`() { + TODO("Implement after Wave 1") + } + + @Test + @Disabled("Wave 1 — implement isLocalOnly() first") + fun `isLocalOnly returns false for SyncStatus COMPLETED with type regular`() { + TODO("Implement after Wave 1") + } + + @Test + @Disabled("Wave 1 — implement isLocalOnly() first") + fun `isLocalOnly returns false for system message with COMPLETED`() { + TODO("Implement after Wave 1") + } +} From 69a6647b20fd0c724266cdd209fa185ad617d444 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 12 Mar 2026 20:55:50 +0100 Subject: [PATCH 03/22] test(01-01): add failing stub tests for ChannelStateImpl.setMessagesPreservingLocalOnly() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 12 @Disabled @Test stubs covering survival cases (FAILED_PERMANENTLY, ephemeral, AWAITING_ATTACHMENTS, pending edit SYNC_NEEDED), collision (server wins), window floor (below-floor excluded, boundary included), empty page (null floor = all local-only), no-DB fallback, DB+state union, COMPLETED exclusion, and DB seed full-replace semantics - Extends ChannelStateImplTestBase to reuse channelState fixture - All stubs disabled with "Wave 2 — implement setMessagesPreservingLocalOnly() first" - Satisfies PRES-01/PRES-04 Wave 0 requirement: test class resolvable by Gradle --tests "*.ChannelStateImplPreservationTest" without build failure --- .../ChannelStateImplPreservationTest.kt | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt new file mode 100644 index 00000000000..2ce514cfb6a --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt @@ -0,0 +1,110 @@ +/* + * 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.MessageType +import io.getstream.chat.android.models.SyncStatus +import io.getstream.chat.android.randomMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test + +/** + * Stub tests for [ChannelStateImpl.setMessagesPreservingLocalOnly]. + * All tests are @Disabled — they will be enabled in Wave 2 once + * setMessagesPreservingLocalOnly() is implemented. + * + * Requirements covered: PRES-01, PRES-04, PRES-05 (state layer) + * + * Setup mirrors [ChannelStateImplTestBase] — extends the base class to reuse + * the channelState fixture and createMessage/createMessages helpers. + */ +@ExperimentalCoroutinesApi +internal class ChannelStateImplPreservationTest : ChannelStateImplTestBase() { + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `failed message survives setMessagesPreservingLocalOnly with non-overlapping incoming`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `ephemeral message survives setMessagesPreservingLocalOnly`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `AWAITING_ATTACHMENTS message survives setMessagesPreservingLocalOnly`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `pending edit SYNC_NEEDED on existing server ID survives setMessagesPreservingLocalOnly`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `server COMPLETED version wins when same ID in both incoming and local-only`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `below-floor local-only excluded above-floor included`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `floor boundary message at exactly floor date is included`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `empty incoming page with null floor includes all local-only`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `localOnlyFromDb empty no-DB path local-only from state messages value preserved`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `localOnlyFromDb non-empty union of state and DB deduped`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `COMPLETED messages not re-inserted from state isLocalOnly returns false`() { + TODO("Implement after Wave 2") + } + + @Test + @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") + fun `setMessages DB seed does NOT preserve local-only full replace semantics intact`() { + TODO("Implement after Wave 2") + } +} From 73765bd4c94ecf721cd6df0fdf551d9e741182e6 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 12 Mar 2026 20:59:39 +0100 Subject: [PATCH 04/22] feat(01-02): implement Message.isLocalOnly() predicate and enable tests - Create MessageLocalOnlyExt.kt with internal fun Message.isLocalOnly() - Covers SYNC_NEEDED, IN_PROGRESS, AWAITING_ATTACHMENTS, FAILED_PERMANENTLY, type==ephemeral, and type==error; returns false for COMPLETED+regular/system - Remove @Disabled from all 8 MessageIsLocalOnlyTest stubs and fill real assertions - All 8 tests pass --- .../channel/internal/MessageLocalOnlyExt.kt | 44 +++++++++++++++++++ .../internal/MessageIsLocalOnlyTest.kt | 38 ++++++++-------- 2 files changed, 63 insertions(+), 19 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageLocalOnlyExt.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageLocalOnlyExt.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageLocalOnlyExt.kt new file mode 100644 index 00000000000..9fc097698be --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageLocalOnlyExt.kt @@ -0,0 +1,44 @@ +/* + * 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.Message +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.models.SyncStatus + +/** + * Returns true if this message is local-only and must be preserved across server message + * window replacements. Local-only messages are never returned by the server after the + * initial send attempt completes. + * + * Covers: + * - Pending sends: SYNC_NEEDED, IN_PROGRESS + * - Attachment upload in-flight: AWAITING_ATTACHMENTS + * - Send failed: FAILED_PERMANENTLY (user must see to retry or dismiss) + * - Ephemeral: type == "ephemeral" (e.g. Giphy previews — server never returns these) + * - Error type: type == "error" (client-generated, not re-delivered by server) + * + * DOES NOT include COMPLETED messages — those are already in the server response. + */ +internal fun Message.isLocalOnly(): Boolean = + syncStatus in setOf( + SyncStatus.SYNC_NEEDED, // new message or pending edit/delete + SyncStatus.IN_PROGRESS, // send in flight + SyncStatus.AWAITING_ATTACHMENTS, // attachment upload pending + SyncStatus.FAILED_PERMANENTLY, // permanent failure — user must see to retry + ) || type == MessageType.EPHEMERAL // Giphy preview etc. — never server-returned + || type == MessageType.ERROR // error type — not re-delivered by server diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt index 324737dbfcf..5243d452c08 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt @@ -19,62 +19,62 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.int import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.randomMessage -import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test /** - * Stub tests for the [Message.isLocalOnly] predicate. - * All tests are @Disabled — they will be enabled in Wave 1 once isLocalOnly() is implemented. + * Unit tests for the [Message.isLocalOnly] predicate. * * Requirements covered: PRES-05 */ internal class MessageIsLocalOnlyTest { @Test - @Disabled("Wave 1 — implement isLocalOnly() first") fun `isLocalOnly returns true for SyncStatus SYNC_NEEDED`() { - TODO("Implement after Wave 1") + val message = randomMessage(syncStatus = SyncStatus.SYNC_NEEDED, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) } @Test - @Disabled("Wave 1 — implement isLocalOnly() first") fun `isLocalOnly returns true for SyncStatus IN_PROGRESS`() { - TODO("Implement after Wave 1") + val message = randomMessage(syncStatus = SyncStatus.IN_PROGRESS, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) } @Test - @Disabled("Wave 1 — implement isLocalOnly() first") fun `isLocalOnly returns true for SyncStatus AWAITING_ATTACHMENTS`() { - TODO("Implement after Wave 1") + val message = randomMessage(syncStatus = SyncStatus.AWAITING_ATTACHMENTS, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) } @Test - @Disabled("Wave 1 — implement isLocalOnly() first") fun `isLocalOnly returns true for SyncStatus FAILED_PERMANENTLY`() { - TODO("Implement after Wave 1") + val message = randomMessage(syncStatus = SyncStatus.FAILED_PERMANENTLY, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) } @Test - @Disabled("Wave 1 — implement isLocalOnly() first") fun `isLocalOnly returns true for type ephemeral with COMPLETED syncStatus`() { - TODO("Implement after Wave 1") + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.EPHEMERAL) + assertTrue(message.isLocalOnly()) } @Test - @Disabled("Wave 1 — implement isLocalOnly() first") fun `isLocalOnly returns true for type error with COMPLETED syncStatus`() { - TODO("Implement after Wave 1") + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.ERROR) + assertTrue(message.isLocalOnly()) } @Test - @Disabled("Wave 1 — implement isLocalOnly() first") fun `isLocalOnly returns false for SyncStatus COMPLETED with type regular`() { - TODO("Implement after Wave 1") + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.REGULAR) + assertFalse(message.isLocalOnly()) } @Test - @Disabled("Wave 1 — implement isLocalOnly() first") fun `isLocalOnly returns false for system message with COMPLETED`() { - TODO("Implement after Wave 1") + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.SYSTEM) + assertFalse(message.isLocalOnly()) } } From a2f01e536d92ac1adc409bcc3dcdc2786dc7a557 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 12 Mar 2026 21:00:33 +0100 Subject: [PATCH 05/22] feat(01-02): add selectLocalOnlyMessagesForChannel to DB layer - Add selectLocalOnlyMessagesForChannel to MessageRepository interface - NoOpMessageRepository overrides it returning emptyList() - MessageDao.selectLocalOnlyForChannel @Query without default args (Room constraint) - DatabaseMessageRepository implements with explicit SyncStatus int constants and MessageType string constants, maps entities via existing toMessage() - RepositoryFacade picks up new method automatically via 'by messageRepository' delegation --- .../internal/DatabaseMessageRepository.kt | 16 ++++++++++++++++ .../domain/message/internal/MessageDao.kt | 12 ++++++++++++ .../persistance/repository/MessageRepository.kt | 11 +++++++++++ .../repository/noop/NoOpMessageRepository.kt | 1 + 4 files changed, 40 insertions(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt index 1f519bb3b9e..838d4389f2e 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationReq import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User @@ -262,6 +263,21 @@ internal class DatabaseMessageRepository( } } + override suspend fun selectLocalOnlyMessagesForChannel(cid: String): List = + messageDao.selectLocalOnlyForChannel( + cid = cid, + syncStatuses = listOf( + SyncStatus.SYNC_NEEDED.status, // -1 + SyncStatus.IN_PROGRESS.status, // 3 + SyncStatus.AWAITING_ATTACHMENTS.status, // 4 + SyncStatus.FAILED_PERMANENTLY.status, // 2 + ), + types = listOf( + MessageType.EPHEMERAL, // "ephemeral" + MessageType.ERROR, // "error" + ), + ).map { entity -> entity.toMessage() } + private suspend fun selectMessagesEntitiesForChannel( cid: String, pagination: AnyChannelPaginationRequest?, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt index 12ba86c4310..7595aefe926 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt @@ -253,6 +253,18 @@ internal interface MessageDao { @Query("DELETE FROM $MESSAGE_ENTITY_TABLE_NAME") suspend fun deleteAll() + @Query( + "SELECT * FROM $MESSAGE_ENTITY_TABLE_NAME " + + "WHERE cid = :cid " + + "AND (syncStatus IN (:syncStatuses) OR type IN (:types))", + ) + @Transaction + suspend fun selectLocalOnlyForChannel( + cid: String, + syncStatuses: List, + types: List, + ): List + private companion object { private const val SQLITE_MAX_VARIABLE_NUMBER: Int = 999 private const val NO_LIMIT: Int = -1 diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageRepository.kt index 621dc86dfb2..941df3472e7 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageRepository.kt @@ -199,4 +199,15 @@ public interface MessageRepository { * Clear messages of this repository. */ public suspend fun clear() + + /** + * Returns all messages for [cid] that are local-only: syncStatus is one of + * SYNC_NEEDED, IN_PROGRESS, AWAITING_ATTACHMENTS, FAILED_PERMANENTLY, or type is + * "ephemeral" or "error". Used by the preservation mechanism before a server response + * replaces the active message window. + * + * @param cid The channel ID (format "type:id"). + * @return List of local-only messages, unordered. + */ + public suspend fun selectLocalOnlyMessagesForChannel(cid: String): List } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpMessageRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpMessageRepository.kt index 09cad10631c..5adaaccda6c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpMessageRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpMessageRepository.kt @@ -57,4 +57,5 @@ internal object NoOpMessageRepository : MessageRepository { override suspend fun selectMessagesForThread(messageId: String, limit: Int): List = emptyList() override suspend fun selectAllUserMessages(userId: String): List = emptyList() override suspend fun selectAllChannelUserMessages(cid: String, userId: String): List = emptyList() + override suspend fun selectLocalOnlyMessagesForChannel(cid: String): List = emptyList() } From 0e103edfd3be9a58d548517a4480816b91be6da8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Thu, 12 Mar 2026 21:00:59 +0100 Subject: [PATCH 06/22] feat(01-02): add oldestLoadedDate to ChannelEntity and bump DB version to 102 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelEntity gains oldestLoadedDate: Date? = null as last field (floor for local-only message window; written in Phase 1, read in Phase 2) - ChatDatabase version bumped 101 -> 102 - fallbackToDestructiveMigration() already in place — no migration script needed - No public API surface changes; no legacy path touched --- .../offline/repository/database/internal/ChatDatabase.kt | 2 +- .../offline/repository/domain/channel/internal/ChannelEntity.kt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt index 6560a0c2f3b..5d63b09bb01 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/database/internal/ChatDatabase.kt @@ -88,7 +88,7 @@ import io.getstream.chat.android.client.internal.offline.repository.domain.user. ThreadOrderEntity::class, DraftMessageEntity::class, ], - version = 101, + version = 102, exportSchema = false, ) @TypeConverters( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt index 794fbf05dd2..26cbcc25d08 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt @@ -87,6 +87,7 @@ internal data class ChannelEntity( val membership: MemberEntity?, val activeLiveLocations: List, val messageCount: Int?, + val oldestLoadedDate: Date? = null, // Floor for local-only message window — written on onQueryChannelResult; read by Phase 2 DB-seed path ) { /** * The channel id in the format messaging:123. From c103d87538502ac2d751034b3a2092350a13a3e5 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 02:35:46 +0100 Subject: [PATCH 07/22] test(01-03): add failing tests for setMessagesPreservingLocalOnly - 12 test cases covering all preservation scenarios - Cases: FAILED_PERMANENTLY/ephemeral/AWAITING_ATTACHMENTS survival, ID collision (server wins), window floor filtering (below excluded, above included, boundary included), null floor (all local-only included), no-DB fallback, state+DB union dedup, COMPLETED not preserved, setMessages full-replace semantics unchanged - Tests fail to compile until implementation is added (TDD RED phase) --- .../ChannelStateImplPreservationTest.kt | 405 ++++++++++++++++-- 1 file changed, 377 insertions(+), 28 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt index 2ce514cfb6a..36e155bb866 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt @@ -20,13 +20,15 @@ import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.randomMessage import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import java.util.Date +import java.util.concurrent.TimeUnit /** - * Stub tests for [ChannelStateImpl.setMessagesPreservingLocalOnly]. - * All tests are @Disabled — they will be enabled in Wave 2 once - * setMessagesPreservingLocalOnly() is implemented. + * Tests for [ChannelStateImpl.setMessagesPreservingLocalOnly]. * * Requirements covered: PRES-01, PRES-04, PRES-05 (state layer) * @@ -36,75 +38,422 @@ import org.junit.jupiter.api.Test @ExperimentalCoroutinesApi internal class ChannelStateImplPreservationTest : ChannelStateImplTestBase() { + // ----------------------------------------------------------------------- + // Case 1: FAILED_PERMANENTLY message survives non-overlapping incoming + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `failed message survives setMessagesPreservingLocalOnly with non-overlapping incoming`() { - TODO("Implement after Wave 2") + val localMsg = randomMessage( + id = "local-failed", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(localMsg)) + + val serverMsg = randomMessage( + id = "server-1", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), + createdLocallyAt = null, + parentId = null, + ) + + channelState.setMessagesPreservingLocalOnly( + incoming = listOf(serverMsg), + localOnlyFromDb = emptyList(), + windowFloor = null, + ) + + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.contains("local-failed"), "FAILED_PERMANENTLY message must survive") + assertTrue(ids.contains("server-1"), "Server message must be present") } + // ----------------------------------------------------------------------- + // Case 2: ephemeral (type==ephemeral, COMPLETED) survives + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `ephemeral message survives setMessagesPreservingLocalOnly`() { - TODO("Implement after Wave 2") + val ephemeral = randomMessage( + id = "local-ephemeral", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.EPHEMERAL, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(ephemeral)) + + val serverMsg = randomMessage( + id = "server-1", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), + createdLocallyAt = null, + parentId = null, + ) + + channelState.setMessagesPreservingLocalOnly( + incoming = listOf(serverMsg), + localOnlyFromDb = emptyList(), + windowFloor = null, + ) + + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.contains("local-ephemeral"), "Ephemeral message must survive") } + // ----------------------------------------------------------------------- + // Case 3: AWAITING_ATTACHMENTS survives + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `AWAITING_ATTACHMENTS message survives setMessagesPreservingLocalOnly`() { - TODO("Implement after Wave 2") + val awaitingMsg = randomMessage( + id = "local-awaiting", + syncStatus = SyncStatus.AWAITING_ATTACHMENTS, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(awaitingMsg)) + + val serverMsg = randomMessage( + id = "server-1", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), + createdLocallyAt = null, + parentId = null, + ) + + channelState.setMessagesPreservingLocalOnly( + incoming = listOf(serverMsg), + localOnlyFromDb = emptyList(), + windowFloor = null, + ) + + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.contains("local-awaiting"), "AWAITING_ATTACHMENTS message must survive") } + // ----------------------------------------------------------------------- + // Case 4: SYNC_NEEDED with server-assigned ID (pending edit) survives + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `pending edit SYNC_NEEDED on existing server ID survives setMessagesPreservingLocalOnly`() { - TODO("Implement after Wave 2") + // A message that was sent (has a server ID) but has a pending edit + val pendingEdit = randomMessage( + id = "server-existing-id", + syncStatus = SyncStatus.SYNC_NEEDED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(pendingEdit)) + + // incoming does NOT include the same ID + val serverMsg = randomMessage( + id = "server-other", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), + createdLocallyAt = null, + parentId = null, + ) + + channelState.setMessagesPreservingLocalOnly( + incoming = listOf(serverMsg), + localOnlyFromDb = emptyList(), + windowFloor = null, + ) + + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.contains("server-existing-id"), "SYNC_NEEDED pending edit must survive") } + // ----------------------------------------------------------------------- + // Case 5: server COMPLETED version wins on ID collision + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `server COMPLETED version wins when same ID in both incoming and local-only`() { - TODO("Implement after Wave 2") + val localVersion = randomMessage( + id = "msg-collision", + syncStatus = SyncStatus.SYNC_NEEDED, + type = MessageType.REGULAR, + text = "local text", + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(localVersion)) + + val serverVersion = randomMessage( + id = "msg-collision", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.REGULAR, + text = "server text", + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + + channelState.setMessagesPreservingLocalOnly( + incoming = listOf(serverVersion), + localOnlyFromDb = emptyList(), + windowFloor = null, + ) + + val messages = channelState.messages.value + val result = messages.find { it.id == "msg-collision" } + assertFalse(result == null, "Message with collision ID must be in result") + assertEquals(SyncStatus.COMPLETED, result!!.syncStatus, "Server COMPLETED version must win") + assertEquals("server text", result.text, "Server text must win") + // Only one message with that ID + assertEquals(1, messages.count { it.id == "msg-collision" }, "Must be exactly one entry for collision ID") } + // ----------------------------------------------------------------------- + // Case 6: window floor filtering — below excluded, above included + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `below-floor local-only excluded above-floor included`() { - TODO("Implement after Wave 2") + val now = System.currentTimeMillis() + val oneDayMs = TimeUnit.DAYS.toMillis(1) + val oneHourMs = TimeUnit.HOURS.toMillis(1) + + // Below floor: 2 days ago; floor is 1 day ago => excluded + val belowFloor = randomMessage( + id = "below-floor", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(now - 2 * oneDayMs), + createdLocallyAt = null, + parentId = null, + ) + // Above floor: 1 hour ago; floor is 1 day ago => included + val aboveFloor = randomMessage( + id = "above-floor", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(now - oneHourMs), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(belowFloor, aboveFloor)) + + val windowFloor = Date(now - oneDayMs) + + channelState.setMessagesPreservingLocalOnly( + incoming = emptyList(), + localOnlyFromDb = emptyList(), + windowFloor = windowFloor, + ) + + val ids = channelState.messages.value.map { it.id } + assertFalse(ids.contains("below-floor"), "Below-floor local-only must be excluded") + assertTrue(ids.contains("above-floor"), "Above-floor local-only must be included") } + // ----------------------------------------------------------------------- + // Case 7: message at exactly windowFloor is included (>= not >) + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `floor boundary message at exactly floor date is included`() { - TODO("Implement after Wave 2") + val now = System.currentTimeMillis() + val oneDayMs = TimeUnit.DAYS.toMillis(1) + val floorTime = now - oneDayMs + + val atFloor = randomMessage( + id = "at-floor", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(floorTime), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(atFloor)) + + val windowFloor = Date(floorTime) + + channelState.setMessagesPreservingLocalOnly( + incoming = emptyList(), + localOnlyFromDb = emptyList(), + windowFloor = windowFloor, + ) + + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.contains("at-floor"), "Message at exactly windowFloor must be included (>= not >)") } + // ----------------------------------------------------------------------- + // Case 8: windowFloor = null — all local-only included + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `empty incoming page with null floor includes all local-only`() { - TODO("Implement after Wave 2") + val old = randomMessage( + id = "local-old", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30)), + createdLocallyAt = null, + parentId = null, + ) + val recent = randomMessage( + id = "local-recent", + syncStatus = SyncStatus.SYNC_NEEDED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(old, recent)) + + channelState.setMessagesPreservingLocalOnly( + incoming = emptyList(), + localOnlyFromDb = emptyList(), + windowFloor = null, + ) + + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.contains("local-old"), "Old local-only must be included when floor is null") + assertTrue(ids.contains("local-recent"), "Recent local-only must be included when floor is null") } + // ----------------------------------------------------------------------- + // Case 9: localOnlyFromDb = emptyList(), in-memory local-only still preserved + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `localOnlyFromDb empty no-DB path local-only from state messages value preserved`() { - TODO("Implement after Wave 2") + val localMsg = randomMessage( + id = "in-memory-local", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(localMsg)) + + val serverMsg = randomMessage( + id = "server-1", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), + createdLocallyAt = null, + parentId = null, + ) + + channelState.setMessagesPreservingLocalOnly( + incoming = listOf(serverMsg), + localOnlyFromDb = emptyList(), // no DB — in-memory fallback + windowFloor = null, + ) + + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.contains("in-memory-local"), "In-memory local-only must be preserved even when localOnlyFromDb is empty") } + // ----------------------------------------------------------------------- + // Case 10: localOnlyFromDb non-empty — union of state and DB, deduped by ID + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `localOnlyFromDb non-empty union of state and DB deduped`() { - TODO("Implement after Wave 2") + // In-memory state has one local-only + val inStateLocal = randomMessage( + id = "state-local", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(inStateLocal)) + + // DB has a different local-only (not in state) + val dbOnlyLocal = randomMessage( + id = "db-local", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)), + createdLocallyAt = null, + parentId = null, + ) + + channelState.setMessagesPreservingLocalOnly( + incoming = emptyList(), + localOnlyFromDb = listOf(dbOnlyLocal), + windowFloor = null, + ) + + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.contains("state-local"), "In-state local-only must be in union result") + assertTrue(ids.contains("db-local"), "DB local-only must be in union result") + // Dedup: no duplicates + assertEquals(ids.size, ids.toSet().size, "No duplicate IDs in result") } + // ----------------------------------------------------------------------- + // Case 11: COMPLETED messages NOT included in survivingLocalOnly + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `COMPLETED messages not re-inserted from state isLocalOnly returns false`() { - TODO("Implement after Wave 2") + val completedMsg = randomMessage( + id = "completed-msg", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(completedMsg)) + + // Incoming is empty — no server messages + channelState.setMessagesPreservingLocalOnly( + incoming = emptyList(), + localOnlyFromDb = emptyList(), + windowFloor = null, + ) + + val ids = channelState.messages.value.map { it.id } + assertFalse(ids.contains("completed-msg"), "COMPLETED message must NOT be preserved (isLocalOnly() = false)") } + // ----------------------------------------------------------------------- + // Case 12: setMessages retains full-replace semantics — NOT preservation + // ----------------------------------------------------------------------- @Test - @Disabled("Wave 2 — implement setMessagesPreservingLocalOnly() first") fun `setMessages DB seed does NOT preserve local-only full replace semantics intact`() { - TODO("Implement after Wave 2") + // Seed state with a local-only message + val localMsg = randomMessage( + id = "local-pending", + syncStatus = SyncStatus.FAILED_PERMANENTLY, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis()), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(localMsg)) + + // setMessages called with server messages (DB seed path — full replace) + val serverMsg = randomMessage( + id = "server-db-seed", + syncStatus = SyncStatus.COMPLETED, + type = MessageType.REGULAR, + createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), + createdLocallyAt = null, + parentId = null, + ) + channelState.setMessages(listOf(serverMsg)) + + val ids = channelState.messages.value.map { it.id } + // Full replace: local-only is gone + assertFalse(ids.contains("local-pending"), "setMessages must NOT preserve local-only (full-replace semantics)") + assertTrue(ids.contains("server-db-seed"), "setMessages must contain the new messages") } } From 9f58487a43c8858d76ffc502736dc20c2133e7e8 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 02:40:06 +0100 Subject: [PATCH 08/22] feat(01-03): implement setMessagesPreservingLocalOnly on ChannelStateImpl - Adds internal fun setMessagesPreservingLocalOnly(incoming, localOnlyFromDb, windowFloor) immediately after setMessages in ChannelStateImpl - Uses _messages.update { } (CAS atomic read-modify-write, no value assignment) - Step 1: gathers local-only from current in-memory state via isLocalOnly() predicate - Step 2: unions with localOnlyFromDb, deduplicates by ID - Step 3: server wins on ID collision (local-only with matching incoming ID dropped) - Step 4: applies windowFloor filter (null = include all; createdAt < floor = exclude) - Step 5: filters incoming through existing shouldIgnoreUpsertion() guard - Step 6: merges and sorts with existing MESSAGE_COMPARATOR - Step 7: syncs quotedMessagesMap and poll indexes (mirrors setMessages post-loop) - setMessages is NOT modified (retains full-replace semantics for DB-seed path) - All 12 ChannelStateImplPreservationTest cases pass; all 8 MessageIsLocalOnlyTest pass --- .../channel/internal/ChannelStateImpl.kt | 64 +++++++++++++++++++ 1 file changed, 64 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 06eadae42d6..28b497b1d6f 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 @@ -291,6 +291,70 @@ internal class ChannelStateImpl( _messages.value = messagesToSet } + /** + * Atomically merges [incoming] server messages with current local-only messages, + * applying the [windowFloor] filter, then writes to [_messages]. + * + * Use instead of [setMessages] when the source is a server response (onQueryChannelResult). + * Do NOT use for the DB-seed path ([updateDataForChannel] calls [setMessages] — DB data + * already includes local-only messages stored by OfflinePlugin). + * + * @param incoming Server message list from the API response. + * @param localOnlyFromDb Local-only messages fetched from DB before this call. + * Pass [emptyList()] when OfflinePlugin is absent — in-memory fallback is automatic. + * @param windowFloor Oldest [createdAt] in [incoming]; null when incoming is empty (no floor). + * Local-only messages with [createdAt] < [windowFloor] are excluded. + * TODO(Phase 2): When incoming is empty, read persisted floor from ChannelEntity instead. + */ + internal fun setMessagesPreservingLocalOnly( + incoming: List, + localOnlyFromDb: List, + windowFloor: Date?, + ) { + val incomingIds = incoming.map { it.id }.toSet() + + _messages.update { current -> + // Step 1: gather local-only from in-memory state (no-DB fallback path) + val fromState = current.filter { it.isLocalOnly() } + + // Step 2: union with DB-sourced local-only; deduplicate by id + val allLocalOnly = (fromState + localOnlyFromDb).distinctBy { it.id } + + // Step 3: server wins on ID collision — drop any local-only whose ID is + // already in the incoming list (server version in incoming replaces it) + val survivingLocalOnly = allLocalOnly.filter { localMsg -> + localMsg.id !in incomingIds + } + + // Step 4: apply window floor — exclude below-floor local-only messages. + // Null floor = first-ever open or empty page → include all local-only. + val inWindowLocalOnly = if (windowFloor == null) { + survivingLocalOnly + } else { + survivingLocalOnly.filter { msg -> + msg.getCreatedAtOrNull()?.let { d -> !d.before(windowFloor) } ?: true + } + } + + // Step 5: filter incoming through the existing shouldIgnoreUpsertion guard + // (handles shadowed users, thread-only messages — same as setMessages) + val validIncoming = incoming.filterNot { shouldIgnoreUpsertion(it) } + + // Step 6: merge and sort using the existing MESSAGE_COMPARATOR (createdAt asc) + (validIncoming + inWindowLocalOnly) + .distinctBy { it.id } + .sortedWith(MESSAGE_COMPARATOR) + } + + // Step 7: sync quoted-message and poll indexes — mirrors setMessages lines 287-291. + // Must run AFTER _messages.update to operate on the final merged list. + for (message in _messages.value) { + message.replyTo?.let { addQuotedMessage(it.id, message.id) } + message.replyMessageId?.let { addQuotedMessage(it, message.id) } + message.poll?.let { registerPollForMessage(it, message.id) } + } + } + /** * Upserts a single message into the current state. * Uses optimized single-element upsert with binary search insertion. From 40b910e6972d595413776098eba4b8266cf50a98 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 02:46:18 +0100 Subject: [PATCH 09/22] feat(01-04): add updateOldestLoadedDateForChannel to channel repository layer - ChannelDao: add updateOldestLoadedDate @Query targeting oldestLoadedDate column - ChannelRepository: add updateOldestLoadedDateForChannel interface method - NoOpChannelRepository: add no-op override returning Unit - DatabaseChannelRepository: implement via channelDao.updateOldestLoadedDate - RepositoryFacade picks up new method automatically via ChannelRepository delegation --- .../repository/domain/channel/internal/ChannelDao.kt | 7 +++++++ .../domain/channel/internal/DatabaseChannelRepository.kt | 4 ++++ .../client/persistance/repository/ChannelRepository.kt | 7 +++++++ .../persistance/repository/noop/NoOpChannelRepository.kt | 2 ++ 4 files changed, 20 insertions(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt index ed820912957..e5c4ab47eb4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt @@ -90,6 +90,13 @@ internal interface ChannelDao { @Query("DELETE FROM $CHANNEL_ENTITY_TABLE_NAME") suspend fun deleteAll() + @Query( + "UPDATE $CHANNEL_ENTITY_TABLE_NAME " + + "SET oldestLoadedDate = :date " + + "WHERE cid = :cid", + ) + suspend fun updateOldestLoadedDate(cid: String, date: Date) + private companion object { private const val NO_LIMIT: Int = -1 } 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..79d802866e7 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 @@ -299,4 +299,8 @@ internal class DatabaseChannelRepository( override suspend fun clear() { dbMutex.withLock { channelDao.deleteAll() } } + + override suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) { + channelDao.updateOldestLoadedDate(cid, date) + } } 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 0b3d4b7e6e7..217275b0972 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 @@ -165,6 +165,13 @@ public interface ChannelRepository { */ public suspend fun clear() + /** + * Updates only the [oldestLoadedDate] field for [cid] in the channel entity. + * Used to persist the window floor after onQueryChannelResult completes. + * Phase 2 reads this value at DB-seed time to apply the floor without a network response. + */ + public suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) + private companion object { private const val NO_LIMIT: Int = -1 } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt index 6d36501a469..7e743b822ae 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt @@ -48,4 +48,6 @@ internal object NoOpChannelRepository : ChannelRepository { override suspend fun evictChannel(cid: String) { /* No-Op */ } override suspend fun clear() { /* No-Op */ } + + override suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) = Unit } From 165d4e68e10e9f3fb0ac4f99391a84a6d2648e07 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 02:57:41 +0100 Subject: [PATCH 10/22] feat(01-04): wire setMessagesPreservingLocalOnly into ChannelLogicImpl call sites - updateMessages: change signature to suspend, add localOnlyFromDb and windowFloor params - Three setMessages call sites in updateMessages replaced with setMessagesPreservingLocalOnly - DB-seed path (updateDataForChannel/shouldRefreshMessages=true) unchanged at state.setMessages - onQueryChannelResult: prefetch localOnlyFromDb and derive windowFloor in coroutineScope.launch - windowFloor derived from min(channel.messages.createdAt); null when incoming is empty - Floor persistence: repository.updateOldestLoadedDateForChannel called when windowFloor != null - ChannelLogicImplTest: add PreservationCallSites nested class with 4 new test cases - ChannelLogicImplTest: update 3 existing call-site tests to verify setMessagesPreservingLocalOnly - ChannelLogicLegacyImpl untouched --- .../channel/internal/ChannelLogicImpl.kt | 31 +++-- .../channel/internal/ChannelLogicImplTest.kt | 106 +++++++++++++++++- 2 files changed, 127 insertions(+), 10 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 8ded7564ae0..bd2ffcacdbf 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 @@ -41,6 +41,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 @@ -143,7 +144,13 @@ internal class ChannelLogicImpl( state.setChannelConfig(channel.config) // Update messages if (limit > 0) { - updateMessages(query, channel) + coroutineScope.launch { + val localOnlyFromDb = repository.selectLocalOnlyMessagesForChannel(cid) + val windowFloor: Date? = channel.messages + .mapNotNull { it.getCreatedAtOrNull() } + .minOrNull() + updateMessages(query, channel, localOnlyFromDb, windowFloor) + } } // Add pinned messages state.addPinnedMessages(channel.pinnedMessages) @@ -385,15 +392,20 @@ internal class ChannelLogicImpl( } } - private fun updateMessages(query: QueryChannelRequest, channel: Channel) { + private suspend fun updateMessages( + query: QueryChannelRequest, + channel: Channel, + localOnlyFromDb: List, + windowFloor: Date?, + ) { when { !query.isFilteringMessages() -> { // Loading newest messages (no pagination): // 1. Clear any cached latest messages (we are replacing the whole list) - // 2. Replace the active messages with the loaded ones + // 2. Replace the active messages with the loaded ones, preserving local-only messages // 3. No pending messages ceiling — we are at the latest messages state.clearCachedLatestMessages() - state.setMessages(channel.messages) + state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) state.setInsideSearch(false) state.setNewestLoadedDate(null) } @@ -401,17 +413,17 @@ internal class ChannelLogicImpl( query.isFilteringAroundIdMessages() -> { // Loading messages around a specific message: // 1. Cache the current messages (for access to latest messages) (unless already inside search) - // 2. Replace the active messages with the loaded ones + // 2. Replace the active messages with the loaded ones, preserving local-only messages // 3. Set ceiling to newest in loaded page — pending messages newer than the page are hidden if (state.insideSearch.value) { // We are currently around a message, don't cache the latest messages, just replace the active set // Otherwise, the cached message set will wrongly hold the previous "around" set, instead of the // latest messages - state.setMessages(channel.messages) + state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) } else { // We are currently showing the latest messages, cache them first, then replace the active set state.cacheLatestMessages() - state.setMessages(channel.messages) + state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) state.setInsideSearch(true) } state.setNewestLoadedDate(channel.messages.lastOrNull()?.getCreatedAtOrNull()) @@ -443,6 +455,11 @@ internal class ChannelLogicImpl( // set this floor — updateDataForChannel (QueryChannels) must not, otherwise a channel-list // preview would incorrectly filter out older pending messages. state.advanceOldestLoadedDate(channel.messages) + // Persist window floor to DB for Phase 2 DB-seed path. + // TODO(Phase 2): when windowFloor is null, read persisted floor from ChannelEntity instead. + if (windowFloor != null) { + repository.updateOldestLoadedDateForChannel(cid, windowFloor) + } // Replace pending messages — server always returns the full latest set (up to 100, ASC). state.setPendingMessages(channel.pendingMessages.map { it.message }) } 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 1dcf83180b6..a461859dcc9 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 @@ -48,6 +48,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -417,6 +418,7 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl, never()).setMessages(any()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).clearCachedLatestMessages() verify(stateImpl, never()).setInsideSearch(any()) @@ -565,7 +567,7 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl).clearCachedLatestMessages() - verify(stateImpl).setMessages(messages) + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl).setInsideSearch(false) } @@ -589,7 +591,7 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl).cacheLatestMessages() - verify(stateImpl).setMessages(messages) + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl).setInsideSearch(true) } @@ -613,7 +615,7 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl, never()).cacheLatestMessages() - verify(stateImpl).setMessages(messages) + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) } @Test @@ -1668,4 +1670,102 @@ internal class ChannelLogicImplTest { } // endregion + + // region PreservationCallSites + + @Nested + inner class PreservationCallSites { + + @Test + fun `onQueryChannelResult success with no filtering calls setMessagesPreservingLocalOnly`() { + // Given + val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = messages, + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessages(any()) + } + + @Test + fun `onQueryChannelResult success with aroundId filtering calls setMessagesPreservingLocalOnly`() { + // Given: insideSearch = false so we exercise the else branch + whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(false)) + val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = messages, + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "m1", 30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessages(any()) + } + + @Test + fun `onQueryChannelResult success inside search calls setMessagesPreservingLocalOnly`() { + // Given: insideSearch = true so we exercise the if branch (no caching) + whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(true)) + val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = messages, + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "m1", 30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessages(any()) + } + + @Test + fun `updateDataForChannel with shouldRefreshMessages true calls setMessages not preservation`() = runTest { + // Given: existing state messages to ensure shouldRefresh branch is taken + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(randomMessage(id = "existing")))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = listOf(incomingMsg), + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + // When: shouldRefreshMessages=true triggers the DB-seed full-replace path + sut.updateDataForChannel(channel = channel, messageLimit = 30, shouldRefreshMessages = true) + // Then: DB-seed path must NOT use preservation — setMessages is required + verify(stateImpl).setMessages(any()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + } + } + + // endregion } From 4f74e2148edd27b7b38ae9659881de0c250bf770 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 09:21:09 +0100 Subject: [PATCH 11/22] feat(02-01): add selectOldestLoadedDate query to ChannelDao - Add single-column SELECT @Query for oldestLoadedDate column - Method returns Date? matching ChannelEntity field nullability - Placed immediately after updateOldestLoadedDate for co-location --- .../repository/domain/channel/internal/ChannelDao.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt index e5c4ab47eb4..83985dd88fe 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt @@ -97,6 +97,12 @@ internal interface ChannelDao { ) suspend fun updateOldestLoadedDate(cid: String, date: Date) + @Query( + "SELECT oldestLoadedDate FROM $CHANNEL_ENTITY_TABLE_NAME " + + "WHERE cid = :cid", + ) + suspend fun selectOldestLoadedDate(cid: String): Date? + private companion object { private const val NO_LIMIT: Int = -1 } From 229fd38cd0b03834ec73171bbff2933c1e581c98 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 09:21:38 +0100 Subject: [PATCH 12/22] feat(02-01): add selectOldestLoadedDateForChannel to repository layer - Add interface method to ChannelRepository returning Date? - Add no-op override returning null in NoOpChannelRepository - Add DB implementation delegating to channelDao.selectOldestLoadedDate - RepositoryFacade picks up via ChannelRepository by channelsRepository delegation --- .../domain/channel/internal/DatabaseChannelRepository.kt | 3 +++ .../client/persistance/repository/ChannelRepository.kt | 6 ++++++ .../persistance/repository/noop/NoOpChannelRepository.kt | 2 ++ 3 files changed, 11 insertions(+) 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 79d802866e7..2fbecdf4050 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 @@ -303,4 +303,7 @@ internal class DatabaseChannelRepository( override suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) { channelDao.updateOldestLoadedDate(cid, date) } + + override suspend fun selectOldestLoadedDateForChannel(cid: String): Date? = + channelDao.selectOldestLoadedDate(cid) } 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 217275b0972..40325678162 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 @@ -172,6 +172,12 @@ public interface ChannelRepository { */ public suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) + /** + * Reads the persisted window floor (oldest loaded date) for [cid] from the channel entity. + * Returns null if the channel entity does not exist or no floor has been persisted yet. + */ + public suspend fun selectOldestLoadedDateForChannel(cid: String): Date? + private companion object { private const val NO_LIMIT: Int = -1 } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt index 7e743b822ae..2986f1cdbf5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt @@ -50,4 +50,6 @@ internal object NoOpChannelRepository : ChannelRepository { override suspend fun clear() { /* No-Op */ } override suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) = Unit + + override suspend fun selectOldestLoadedDateForChannel(cid: String): Date? = null } From 1551ef236fe8881dd88374614bb43c864ab95c32 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 09:28:24 +0100 Subject: [PATCH 13/22] test(02-02): add failing tests for pagination and reconnect preservation - Updated filteringOlderMessages and isFilteringNewerMessages tests to verify setMessagesPreservingLocalOnly replaces upsertMessages - Added new tests for endReached=true path (null floor), reconnect path (isChannelsStateUpdate=false), and else-upsert branch in updateDataForChannel - Updated DB-seed tests to pass isChannelsStateUpdate=true explicitly - Added isNull import for Mockito RED phase: 8 tests failing as expected --- .../channel/internal/ChannelLogicImplTest.kt | 172 ++++++++++++++++-- 1 file changed, 155 insertions(+), 17 deletions(-) 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 a461859dcc9..4c6d9e005e1 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 @@ -50,6 +50,7 @@ import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -619,8 +620,8 @@ internal class ChannelLogicImplTest { } @Test - fun `should upsert messages and trim oldest when loading newer messages`() { - // Given + fun `should preserve local-only and trim oldest when loading newer messages`() { + // Given — limit=30, messages.size=30 → endReached=false val messages = (1..30).map { randomMessage(id = "m$it") } val channel = randomChannel( id = "123", @@ -635,8 +636,8 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).upsertMessages(messages) + // Then — preservation replaces plain upsert; trim still applied + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl).trimOldestMessages() } @@ -685,7 +686,7 @@ internal class ChannelLogicImplTest { } @Test - fun `should upsert messages and trim newest when loading older messages`() { + fun `should preserve local-only and trim newest when loading older messages`() { // Given val messages = (1..10).map { randomMessage(id = "m$it") } val channel = randomChannel( @@ -701,8 +702,8 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m20", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).upsertMessages(messages) + // Then — preservation replaces plain upsert; trim still applied + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl).trimNewestMessages() } } @@ -1244,8 +1245,9 @@ internal class ChannelLogicImplTest { } @Test - fun `should set messages sorted by createdAt when messageLimit is positive`() = runTest { + fun `should set messages sorted by createdAt when messageLimit is positive (DB-seed path)`() = runTest { // Given - messages in descending order (as returned by the DB query) + // isChannelsStateUpdate=true simulates the DB-seed path where setMessages is required val olderMessage = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) val newerMessage = randomMessage(id = "m2", createdAt = Date(2000L), createdLocallyAt = null) val channel = randomChannel( @@ -1259,7 +1261,7 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) // When - sut.updateDataForChannel(channel = channel, messageLimit = 30) + sut.updateDataForChannel(channel = channel, messageLimit = 30, isChannelsStateUpdate = true) // Then - messages must be sorted ascending before being set into state verify(stateImpl).setMessages(listOf(olderMessage, newerMessage)) } @@ -1388,7 +1390,7 @@ internal class ChannelLogicImplTest { } @Test - fun `should upsert messages when state has messages and incoming are contiguous`() = runTest { + fun `should preserve local-only when state has messages and incoming are contiguous`() = runTest { val existingMsg = randomMessage(id = "existing", createdAt = Date(1000L), createdLocallyAt = null) whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) val incomingMsg = randomMessage(id = "new", createdAt = Date(500L), createdLocallyAt = null) @@ -1403,7 +1405,9 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) sut.updateDataForChannel(channel = channel, messageLimit = 30) - verify(stateImpl).upsertMessages(listOf(incomingMsg)) + // else branch now uses preservation instead of plain upsert + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).setMessages(any()) verify(stateImpl, never()).upsertCachedLatestMessages(any()) verify(stateImpl, never()).setEndOfNewerMessages(any()) @@ -1458,7 +1462,8 @@ internal class ChannelLogicImplTest { } @Test - fun `should replace messages when shouldRefreshMessages is true regardless of existing state`() = runTest { + fun `should replace messages when shouldRefreshMessages is true and isChannelsStateUpdate is true`() = runTest { + // DB-seed path: isChannelsStateUpdate=true means OfflinePlugin already includes local-only in DB data val existingMsg = randomMessage(id = "old", createdAt = Date(1000L), createdLocallyAt = null) whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) @@ -1472,10 +1477,16 @@ internal class ChannelLogicImplTest { memberCount = 0, watcherCount = 0, ) - sut.updateDataForChannel(channel = channel, messageLimit = 30, shouldRefreshMessages = true) + sut.updateDataForChannel( + channel = channel, + messageLimit = 30, + shouldRefreshMessages = true, + isChannelsStateUpdate = true, + ) verify(stateImpl).setMessages(listOf(incomingMsg)) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).upsertCachedLatestMessages(any()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) } } @@ -1745,8 +1756,9 @@ internal class ChannelLogicImplTest { } @Test - fun `updateDataForChannel with shouldRefreshMessages true calls setMessages not preservation`() = runTest { + fun `updateDataForChannel with shouldRefreshMessages true and isChannelsStateUpdate true calls setMessages not preservation`() = runTest { // Given: existing state messages to ensure shouldRefresh branch is taken + // isChannelsStateUpdate=true is the DB-seed path (updateStateFromDatabase caller) whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(randomMessage(id = "existing")))) val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) val channel = randomChannel( @@ -1759,12 +1771,138 @@ internal class ChannelLogicImplTest { memberCount = 0, watcherCount = 0, ) - // When: shouldRefreshMessages=true triggers the DB-seed full-replace path - sut.updateDataForChannel(channel = channel, messageLimit = 30, shouldRefreshMessages = true) - // Then: DB-seed path must NOT use preservation — setMessages is required + // When: DB-seed path — isChannelsStateUpdate=true means OfflinePlugin already includes local-only in DB data + sut.updateDataForChannel( + channel = channel, + messageLimit = 30, + shouldRefreshMessages = true, + isChannelsStateUpdate = true, + ) + // Then: DB-seed path must NOT use preservation — setMessages full-replace is required verify(stateImpl).setMessages(any()) verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) } + + @Test + fun `onQueryChannelResult filteringOlderMessages calls setMessagesPreservingLocalOnly not upsertMessages`() { + // Given — LESS_THAN triggers filteringOlderMessages + val messages = (1..10).map { randomMessage(id = "m$it") } + val channel = randomChannel( + id = "123", + type = "messaging", + messages = messages, + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m20", 30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then — preservation replaces plain upsert; trimNewestMessages still called + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl).trimNewestMessages() + } + + @Test + fun `onQueryChannelResult isFilteringNewerMessages not end reached calls setMessagesPreservingLocalOnly`() { + // Given — GREATER_THAN + limit=5, messages.size=5 → endReached=false + val messages = (1..5).map { randomMessage(id = "m$it") } + val channel = randomChannel( + id = "123", + type = "messaging", + messages = messages, + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 5) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then — preservation replaces plain upsert; trimOldestMessages still called + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl).trimOldestMessages() + } + + @Test + fun `onQueryChannelResult isFilteringNewerMessages end reached calls setMessagesPreservingLocalOnly with null floor`() { + // Given — GREATER_THAN + limit=30, messages.size=5 → endReached=true + val messages = (1..5).map { randomMessage(id = "m$it") } + val channel = randomChannel( + id = "123", + type = "messaging", + messages = messages, + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then — end reached: setMessagesPreservingLocalOnly with null floor (no-ceiling path) + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), isNull()) + verify(stateImpl, never()).upsertMessages(any()) + // And exit-search state management still happens + verify(stateImpl).clearCachedLatestMessages() + verify(stateImpl).setInsideSearch(false) + } + + @Test + fun `updateDataForChannel with shouldRefreshMessages true and isChannelsStateUpdate false calls preservation`() = runTest { + // Given: SyncManager reconnect path — shouldRefreshMessages=true but isChannelsStateUpdate=false + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(randomMessage(id = "existing")))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = listOf(incomingMsg), + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + // When: reconnect path — isChannelsStateUpdate=false (default) + sut.updateDataForChannel( + channel = channel, + messageLimit = 30, + shouldRefreshMessages = true, + isChannelsStateUpdate = false, + ) + // Then: SyncManager reconnect must use preservation to keep local-only messages + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessages(any()) + } + + @Test + fun `updateDataForChannel else upsert branch calls setMessagesPreservingLocalOnly`() = runTest { + // Given: contiguous incoming messages (no gap, no refresh, has existing messages) + val existingMsg = randomMessage(id = "existing", createdAt = Date(1000L), createdLocallyAt = null) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + val incomingMsg = randomMessage(id = "new", createdAt = Date(500L), createdLocallyAt = null) + val channel = randomChannel( + id = "123", + type = "messaging", + messages = listOf(incomingMsg), + members = emptyList(), + watchers = emptyList(), + read = emptyList(), + memberCount = 0, + watcherCount = 0, + ) + // When: else branch (contiguous, not refresh, not insideSearch, no gap) + sut.updateDataForChannel(channel = channel, messageLimit = 30) + // Then: else branch uses preservation not plain upsert + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).upsertMessages(any()) + } } // endregion From 065797e7c808801569ad13ec8f6642362e7a2dca Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 09:29:46 +0100 Subject: [PATCH 14/22] feat(02-02): wire setMessagesPreservingLocalOnly into pagination branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - filteringOlderMessages branch: replace upsertMessages with setMessagesPreservingLocalOnly(messages, localOnlyFromDb, windowFloor) - isFilteringNewerMessages endReached=false: replace upsertMessages with setMessagesPreservingLocalOnly(messages, localOnlyFromDb, windowFloor) - isFilteringNewerMessages endReached=true: replace upsertMessages with setMessagesPreservingLocalOnly(messages, localOnlyFromDb, null) — null floor signals no-ceiling (reached latest messages) - trimOldestMessages / trimNewestMessages calls preserved in both branches GREEN: pagination preservation tests pass (Task 1 complete) --- .../logic/channel/internal/ChannelLogicImpl.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 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 bd2ffcacdbf..efe493e5298 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 @@ -430,24 +430,25 @@ internal class ChannelLogicImpl( } query.isFilteringNewerMessages() -> { - // Loading newer messages - upsert - state.upsertMessages(channel.messages) - state.trimOldestMessages() + // Loading newer messages — preserve local-only messages within the window val endReached = query.messagesLimit() > channel.messages.size if (endReached) { - // Reached the latest messages — remove the ceiling + // Reached the latest messages — no ceiling needed; pass null floor to include all local-only + state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, null) state.clearCachedLatestMessages() state.setInsideSearch(false) state.setNewestLoadedDate(null) } else { - // Still paginating toward latest — advance ceiling to include newly loaded messages + // Still paginating toward latest — advance ceiling; preserve local-only within floor + state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) state.advanceNewestLoadedDate(channel.messages.lastOrNull()?.getCreatedAtOrNull()) } + state.trimOldestMessages() } query.filteringOlderMessages() -> { - // Loading older messages - prepend; ceiling does not change - state.upsertMessages(channel.messages) + // Loading older messages — preserve local-only messages at or above the new window floor + state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) state.trimNewestMessages() } } From e897300a415c2720c5c7d2df48600d2f2505e126 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 09:31:48 +0100 Subject: [PATCH 15/22] feat(02-02): wire updateDataForChannel reconnect path with preservation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prefetch localOnlyFromDb and persistedFloor from repository at top of messageLimit > 0 block - shouldRefreshMessages branch: isChannelsStateUpdate=true uses setMessages (DB-seed; local-only already in DB), isChannelsStateUpdate=false uses setMessagesPreservingLocalOnly (SyncManager reconnect) - else upsert branch: replaced upsertMessages with setMessagesPreservingLocalOnly using persistedFloor - insideSearch and hasGap branches unchanged (route to cache, not active messages) - Removed TODO(Phase 2) comment from updateMessages — floor is now read in updateDataForChannel GREEN: all 92 tests pass — full trigger coverage complete --- .../channel/internal/ChannelLogicImpl.kt | 23 ++++++++++++++----- 1 file changed, 17 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 efe493e5298..846e7192e15 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 @@ -313,14 +313,26 @@ internal class ChannelLogicImpl( state.setPendingMessages(channel.pendingMessages.map(PendingMessage::message)) // Update messages based on the relationship between the incoming page and existing state. if (messageLimit > 0) { + // Prefetch local-only messages and the persisted window floor so that preservation can + // re-inject any local-only messages that the server page does not include. + val localOnlyFromDb = repository.selectLocalOnlyMessagesForChannel(cid) + val persistedFloor: Date? = repository.selectOldestLoadedDateForChannel(cid) val sortedMessages = withContext(Dispatchers.Default) { channel.messages.sortedBy { it.getCreatedAtOrNull() } } val currentMessages = state.messages.value when { shouldRefreshMessages || currentMessages.isEmpty() -> { - // Initial load (DB seed or first fetch) or explicit refresh — full replace - state.setMessages(sortedMessages) + if (isChannelsStateUpdate) { + // DB-seed path (updateStateFromDatabase → isChannelsStateUpdate=true): + // OfflinePlugin already includes local-only messages in the DB data. + // Full-replace is intentional here — preservation would double-inject them. + state.setMessages(sortedMessages) + } else { + // SyncManager reconnect path (isChannelsStateUpdate=false): + // Local-only messages are NOT in the server page; they must survive. + state.setMessagesPreservingLocalOnly(sortedMessages, localOnlyFromDb, persistedFloor) + } state.setEndOfOlderMessages(channel.messages.size < messageLimit) } state.insideSearch.value -> { @@ -339,8 +351,8 @@ internal class ChannelLogicImpl( } else -> { // Incoming messages are contiguous with (or overlap) the current window. - // Upsert preserves the user's scroll position while adding/updating messages. - state.upsertMessages(sortedMessages) + // Preserve local-only messages that the server page does not include. + state.setMessagesPreservingLocalOnly(sortedMessages, localOnlyFromDb, persistedFloor) state.setEndOfOlderMessages(channel.messages.size < messageLimit) } } @@ -456,8 +468,7 @@ internal class ChannelLogicImpl( // set this floor — updateDataForChannel (QueryChannels) must not, otherwise a channel-list // preview would incorrectly filter out older pending messages. state.advanceOldestLoadedDate(channel.messages) - // Persist window floor to DB for Phase 2 DB-seed path. - // TODO(Phase 2): when windowFloor is null, read persisted floor from ChannelEntity instead. + // Persist window floor to DB so updateDataForChannel can read it on reconnect. if (windowFloor != null) { repository.updateOldestLoadedDateForChannel(cid, windowFloor) } From f02be55e6a142d9857ef4d6615f5aaf3403349ff Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 09:35:44 +0100 Subject: [PATCH 16/22] test(02-03): add PaginationPreservation nested test class - 4 tests verifying filteringOlderMessages and isFilteringNewerMessages branches call setMessagesPreservingLocalOnly (not upsertMessages) - end-reached test verifies null windowFloor (isNull()) - mid-page test verifies advanceNewestLoadedDate still called --- .../channel/internal/ChannelLogicImplTest.kt | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) 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 4c6d9e005e1..a5b151e4198 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 @@ -1906,4 +1906,82 @@ internal class ChannelLogicImplTest { } // endregion + + // region PaginationPreservation + + @Nested + inner class PaginationPreservation { + + @Test + fun `filteringOlderMessages calls setMessagesPreservingLocalOnly not upsertMessages`() { + // Given + val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) + val channel = randomChannel( + id = "123", type = "messaging", messages = messages, + members = emptyList(), watchers = emptyList(), read = emptyList(), + memberCount = 0, watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m0", 30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).setMessages(any()) + } + + @Test + fun `filteringNewerMessages mid-page calls setMessagesPreservingLocalOnly not upsertMessages`() { + // Given: size == limit → not end-reached + val messages = List(30) { randomMessage(id = "m$it") } + val channel = randomChannel( + id = "123", type = "messaging", messages = messages, + members = emptyList(), watchers = emptyList(), read = emptyList(), + memberCount = 0, watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m-1", 30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).setMessages(any()) + } + + @Test + fun `filteringNewerMessages end-reached calls setMessagesPreservingLocalOnly with null floor`() { + // Given: size < limit → end-reached + val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) + val channel = randomChannel( + id = "123", type = "messaging", messages = messages, + members = emptyList(), watchers = emptyList(), read = emptyList(), + memberCount = 0, watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then: null windowFloor = no ceiling restriction (at latest messages) + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), isNull()) + verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl, never()).setMessages(any()) + } + + @Test + fun `filteringNewerMessages mid-page still advances newest loaded date`() { + // Given: size == limit → not end-reached + val messages = List(30) { randomMessage(id = "m$it") } + val channel = randomChannel( + id = "123", type = "messaging", messages = messages, + members = emptyList(), watchers = emptyList(), read = emptyList(), + memberCount = 0, watcherCount = 0, + ) + val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m-1", 30) + // When + sut.onQueryChannelResult(query, Result.Success(channel)) + // Then: ceiling must still advance so pending messages above the page are hidden + verify(stateImpl).advanceNewestLoadedDate(anyOrNull()) + } + } + + // endregion } From 353ee5aa1c00be00ec9799138315f136a0ddc822 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 09:37:12 +0100 Subject: [PATCH 17/22] test(02-03): add ReconnectPreservation nested test class - 3 tests covering updateDataForChannel reconnect path, DB-seed path, and else-upsert branch - reconnect (isChannelsStateUpdate=false) verifies setMessagesPreservingLocalOnly - DB-seed (isChannelsStateUpdate=true) verifies setMessages full-replace (regression guard) - else-upsert branch verifies setMessagesPreservingLocalOnly, never upsertMessages - Full testDebugUnitTest suite: BUILD SUCCESSFUL, zero failures --- .../channel/internal/ChannelLogicImplTest.kt | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) 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 a5b151e4198..d759fd57938 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 @@ -1984,4 +1984,78 @@ internal class ChannelLogicImplTest { } // endregion + + // region ReconnectPreservation + + @Nested + inner class ReconnectPreservation { + + @Test + fun `updateDataForChannel reconnect path calls setMessagesPreservingLocalOnly`() = runTest { + // Given: isChannelsStateUpdate=false (SyncManager reconnect), shouldRefreshMessages=true + val incomingMsg = randomMessage(id = "server1") + val channel = randomChannel( + id = "123", type = "messaging", messages = listOf(incomingMsg), + members = emptyList(), watchers = emptyList(), read = emptyList(), + memberCount = 0, watcherCount = 0, + ) + // When: reconnect path — isChannelsStateUpdate defaults to false + sut.updateDataForChannel( + channel = channel, + messageLimit = 30, + shouldRefreshMessages = true, + isChannelsStateUpdate = false, + ) + // Then + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessages(any()) + } + + @Test + fun `updateDataForChannel DB-seed path calls setMessages not preservation`() = runTest { + // Given: isChannelsStateUpdate=true (updateStateFromDatabase DB-seed) + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(randomMessage(id = "existing")))) + val incomingMsg = randomMessage(id = "new") + val channel = randomChannel( + id = "123", type = "messaging", messages = listOf(incomingMsg), + members = emptyList(), watchers = emptyList(), read = emptyList(), + memberCount = 0, watcherCount = 0, + ) + // When: DB-seed path — isChannelsStateUpdate=true + sut.updateDataForChannel( + channel = channel, + messageLimit = 30, + shouldRefreshMessages = true, + isChannelsStateUpdate = true, + ) + // Then: full-replace required, local-only messages already in DB data + verify(stateImpl).setMessages(any()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + } + + @Test + fun `updateDataForChannel upsert else-branch calls setMessagesPreservingLocalOnly`() = runTest { + // Given: currentMessages non-empty, contiguous with incoming, shouldRefresh=false, not insideSearch, no gap + val existingMsg = randomMessage(id = "existing") + whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) + val incomingMsg = randomMessage(id = "incoming") + val channel = randomChannel( + id = "123", type = "messaging", messages = listOf(incomingMsg), + members = emptyList(), watchers = emptyList(), read = emptyList(), + memberCount = 0, watcherCount = 0, + ) + // When: shouldRefreshMessages=false, currentMessages non-empty → falls to else branch + sut.updateDataForChannel( + channel = channel, + messageLimit = 30, + shouldRefreshMessages = false, + isChannelsStateUpdate = false, + ) + // Then + verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).upsertMessages(any()) + } + } + + // endregion } From b09d9fc34b7da4e5f8c22f2b6caf65ccdd818e97 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Fri, 13 Mar 2026 09:54:35 +0100 Subject: [PATCH 18/22] fix(phase-2): use upsertMessagesPreservingLocalOnly for pagination branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pagination branches (filteringOlderMessages, isFilteringNewerMessages) were incorrectly calling setMessagesPreservingLocalOnly which *replaces* the entire message list with only the new page. Instead they must *merge* the new page into existing state so prior pagination results are preserved. Add upsertMessagesPreservingLocalOnly to ChannelStateImpl — identical to setMessagesPreservingLocalOnly except step 6 keeps existingServer messages alongside incoming + local-only, preventing page drops on pagination. Also fix endReached branch to pass windowFloor instead of null: the floor must not reset when we reach the newest end; it stays at the oldest loaded point across the full pagination session. Tests updated to assert upsertMessagesPreservingLocalOnly for pagination paths and setMessagesPreservingLocalOnly for set/replace paths only. Fix flaky else-branch test to use ID overlap for deterministic hasGap=false. Co-Authored-By: Claude Sonnet 4.6 --- .../channel/internal/ChannelLogicImpl.kt | 15 ++--- .../channel/internal/ChannelStateImpl.kt | 48 ++++++++++++++- .../channel/internal/ChannelLogicImplTest.kt | 60 +++++++++++-------- 3 files changed, 89 insertions(+), 34 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 846e7192e15..c6e858b05cd 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 @@ -442,25 +442,26 @@ internal class ChannelLogicImpl( } query.isFilteringNewerMessages() -> { - // Loading newer messages — preserve local-only messages within the window + // Loading newer messages — merge new page into existing, preserve local-only within window val endReached = query.messagesLimit() > channel.messages.size if (endReached) { - // Reached the latest messages — no ceiling needed; pass null floor to include all local-only - state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, null) + // Reached the latest messages — merge final page; keep the window floor (do NOT pass null, + // passing null would include all local-only regardless of position; floor stays at oldest loaded) + state.upsertMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) state.clearCachedLatestMessages() state.setInsideSearch(false) state.setNewestLoadedDate(null) } else { - // Still paginating toward latest — advance ceiling; preserve local-only within floor - state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) + // Still paginating toward latest — merge page, advance ceiling, preserve local-only within floor + state.upsertMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) state.advanceNewestLoadedDate(channel.messages.lastOrNull()?.getCreatedAtOrNull()) } state.trimOldestMessages() } query.filteringOlderMessages() -> { - // Loading older messages — preserve local-only messages at or above the new window floor - state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) + // Loading older messages — merge old page into existing, preserve local-only within new floor + state.upsertMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) state.trimNewestMessages() } } 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 28b497b1d6f..971187a43cc 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 @@ -340,7 +340,8 @@ internal class ChannelStateImpl( // (handles shadowed users, thread-only messages — same as setMessages) val validIncoming = incoming.filterNot { shouldIgnoreUpsertion(it) } - // Step 6: merge and sort using the existing MESSAGE_COMPARATOR (createdAt asc) + // Step 6: replace — discard existing server messages, keep only incoming + local-only. + // Use upsertMessagesPreservingLocalOnly when merging a pagination page instead. (validIncoming + inWindowLocalOnly) .distinctBy { it.id } .sortedWith(MESSAGE_COMPARATOR) @@ -355,6 +356,51 @@ internal class ChannelStateImpl( } } + /** + * Merges [incoming] server messages into the current message state, preserving existing + * server messages from prior pages and local-only messages within the window. + * + * Use for pagination branches ([filteringOlderMessages], [isFilteringNewerMessages]) where + * existing pages must be retained. For initial loads and around-message jumps, use + * [setMessagesPreservingLocalOnly] instead. + * + * @param incoming New page of server messages from the API response. + * @param localOnlyFromDb Local-only messages fetched from DB before this call. + * @param windowFloor Oldest [createdAt] in [incoming]; null to include all local-only. + */ + internal fun upsertMessagesPreservingLocalOnly( + incoming: List, + localOnlyFromDb: List, + windowFloor: Date?, + ) { + val incomingIds = incoming.map { it.id }.toSet() + + _messages.update { current -> + val fromState = current.filter { it.isLocalOnly() } + val allLocalOnly = (fromState + localOnlyFromDb).distinctBy { it.id } + val survivingLocalOnly = allLocalOnly.filter { it.id !in incomingIds } + val inWindowLocalOnly = if (windowFloor == null) { + survivingLocalOnly + } else { + survivingLocalOnly.filter { msg -> + msg.getCreatedAtOrNull()?.let { d -> !d.before(windowFloor) } ?: true + } + } + val validIncoming = incoming.filterNot { shouldIgnoreUpsertion(it) } + // Merge: keep existing server messages + add new page + surviving local-only + val existingServer = current.filterNot { it.isLocalOnly() } + (existingServer + validIncoming + inWindowLocalOnly) + .distinctBy { it.id } + .sortedWith(MESSAGE_COMPARATOR) + } + + for (message in _messages.value) { + message.replyTo?.let { addQuotedMessage(it.id, message.id) } + message.replyMessageId?.let { addQuotedMessage(it, message.id) } + message.poll?.let { registerPollForMessage(it, message.id) } + } + } + /** * Upserts a single message into the current state. * Uses optimized single-element upsert with binary search insertion. 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 d759fd57938..b63985b5e9e 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 @@ -636,8 +636,8 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — preservation replaces plain upsert; trim still applied - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + // Then — pagination merges (not replaces); trim still applied + verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl).trimOldestMessages() } @@ -702,8 +702,8 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m20", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — preservation replaces plain upsert; trim still applied - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + // Then — pagination merges (not replaces); trim still applied + verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl).trimNewestMessages() } } @@ -1784,7 +1784,7 @@ internal class ChannelLogicImplTest { } @Test - fun `onQueryChannelResult filteringOlderMessages calls setMessagesPreservingLocalOnly not upsertMessages`() { + fun `onQueryChannelResult filteringOlderMessages calls upsertMessagesPreservingLocalOnly not setMessages`() { // Given — LESS_THAN triggers filteringOlderMessages val messages = (1..10).map { randomMessage(id = "m$it") } val channel = randomChannel( @@ -1800,14 +1800,15 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m20", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — preservation replaces plain upsert; trimNewestMessages still called - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + // Then — pagination merges; trimNewestMessages still called + verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl).trimNewestMessages() } @Test - fun `onQueryChannelResult isFilteringNewerMessages not end reached calls setMessagesPreservingLocalOnly`() { + fun `onQueryChannelResult isFilteringNewerMessages not end reached calls upsertMessagesPreservingLocalOnly`() { // Given — GREATER_THAN + limit=5, messages.size=5 → endReached=false val messages = (1..5).map { randomMessage(id = "m$it") } val channel = randomChannel( @@ -1823,14 +1824,15 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 5) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — preservation replaces plain upsert; trimOldestMessages still called - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + // Then — pagination merges; trimOldestMessages still called + verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl).trimOldestMessages() } @Test - fun `onQueryChannelResult isFilteringNewerMessages end reached calls setMessagesPreservingLocalOnly with null floor`() { + fun `onQueryChannelResult isFilteringNewerMessages end reached calls upsertMessagesPreservingLocalOnly with page floor`() { // Given — GREATER_THAN + limit=30, messages.size=5 → endReached=true val messages = (1..5).map { randomMessage(id = "m$it") } val channel = randomChannel( @@ -1846,8 +1848,9 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — end reached: setMessagesPreservingLocalOnly with null floor (no-ceiling path) - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), isNull()) + // Then — end reached: merge with the page's floor (not null); window floor is preserved + verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl, never()).upsertMessages(any()) // And exit-search state management still happens verify(stateImpl).clearCachedLatestMessages() @@ -1913,7 +1916,7 @@ internal class ChannelLogicImplTest { inner class PaginationPreservation { @Test - fun `filteringOlderMessages calls setMessagesPreservingLocalOnly not upsertMessages`() { + fun `filteringOlderMessages calls upsertMessagesPreservingLocalOnly not setMessages`() { // Given val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) val channel = randomChannel( @@ -1924,14 +1927,15 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m0", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + // Then — pagination must merge, not replace + verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).setMessages(any()) } @Test - fun `filteringNewerMessages mid-page calls setMessagesPreservingLocalOnly not upsertMessages`() { + fun `filteringNewerMessages mid-page calls upsertMessagesPreservingLocalOnly not setMessages`() { // Given: size == limit → not end-reached val messages = List(30) { randomMessage(id = "m$it") } val channel = randomChannel( @@ -1942,14 +1946,15 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m-1", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + // Then — pagination must merge, not replace + verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).setMessages(any()) } @Test - fun `filteringNewerMessages end-reached calls setMessagesPreservingLocalOnly with null floor`() { + fun `filteringNewerMessages end-reached calls upsertMessagesPreservingLocalOnly with page floor`() { // Given: size < limit → end-reached val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) val channel = randomChannel( @@ -1960,8 +1965,9 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then: null windowFloor = no ceiling restriction (at latest messages) - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), isNull()) + // Then: page's windowFloor passed (not null) — window floor stays at oldest loaded, not reset + verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).setMessages(any()) } @@ -2035,16 +2041,18 @@ internal class ChannelLogicImplTest { @Test fun `updateDataForChannel upsert else-branch calls setMessagesPreservingLocalOnly`() = runTest { - // Given: currentMessages non-empty, contiguous with incoming, shouldRefresh=false, not insideSearch, no gap - val existingMsg = randomMessage(id = "existing") + // Given: currentMessages non-empty, incoming overlaps with existing (same ID → hasGap = false) + // Using same ID guarantees no gap (ID overlap short-circuits the date check in hasGap) + val sharedId = "shared-msg" + val existingMsg = randomMessage(id = sharedId) whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) - val incomingMsg = randomMessage(id = "incoming") + val incomingMsg = randomMessage(id = sharedId) // same ID = overlap → no gap val channel = randomChannel( id = "123", type = "messaging", messages = listOf(incomingMsg), members = emptyList(), watchers = emptyList(), read = emptyList(), memberCount = 0, watcherCount = 0, ) - // When: shouldRefreshMessages=false, currentMessages non-empty → falls to else branch + // When: shouldRefreshMessages=false, currentMessages non-empty, no gap → falls to else branch sut.updateDataForChannel( channel = channel, messageLimit = 30, From d0a43f63eb004d5f7563c52afc351976b18f58ab Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 16 Mar 2026 13:51:06 +0100 Subject: [PATCH 19/22] Improve handling of pending and local-only messages in ChannelState. --- .../api/stream-chat-android-client.api | 2 + .../domain/channel/internal/ChannelDao.kt | 13 - .../domain/channel/internal/ChannelEntity.kt | 2 +- .../internal/DatabaseChannelRepository.kt | 7 - .../internal/QueryChannelListenerState.kt | 2 +- .../channel/internal/ChannelLogicImpl.kt | 147 +--- .../channel/internal/ChannelStateImpl.kt | 293 ++----- .../channel/internal/MessageLocalOnlyExt.kt | 44 - .../internal/MessagesPaginationState.kt | 263 ++++++ .../internal/PendingMessagesManager.kt | 141 ---- .../repository/ChannelRepository.kt | 13 - .../repository/noop/NoOpChannelRepository.kt | 6 - .../client/utils/message/MessageUtils.kt | 23 + .../UploadAttachmentsIntegrationTests.kt | 2 + .../channel/internal/ChannelLogicImplTest.kt | 781 +----------------- .../ChannelStateImplNonChannelStatesTest.kt | 173 +--- .../ChannelStateImplPendingMessagesTest.kt | 59 +- .../ChannelStateImplPreservationTest.kt | 459 ---------- .../internal/MessageIsLocalOnlyTest.kt | 80 -- .../MessagesPaginationManagerImplTest.kt | 676 +++++++++++++++ .../internal/PendingMessagesManagerTest.kt | 402 --------- .../client/utils/message/MessageUtilsTest.kt | 50 ++ 22 files changed, 1198 insertions(+), 2440 deletions(-) delete mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageLocalOnlyExt.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt delete mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManager.kt delete mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt delete mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationManagerImplTest.kt delete mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManagerTest.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 2147e9e86d7..218ba735b74 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -3194,6 +3194,7 @@ public final class io/getstream/chat/android/client/internal/offline/repository/ public fun selectDraftMessageByParentId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectDraftMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectIdsBySyncStatus (Lio/getstream/chat/android/models/SyncStatus;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun selectLocalOnlyForChannel (Ljava/lang/String;Ljava/util/List;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectMessagesWithPoll (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectWaitForAttachments (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun updateMessageInnerEntity (Lio/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageInnerEntity;)V @@ -3514,6 +3515,7 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract fun selectDraftMessageByParentId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectDraftMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectDraftMessagesByCid (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun selectLocalOnlyMessagesForChannel (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectMessage (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectMessageBySyncState (Lio/getstream/chat/android/models/SyncStatus;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun selectMessageIdsBySyncState (Lio/getstream/chat/android/models/SyncStatus;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt index 83985dd88fe..ed820912957 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelDao.kt @@ -90,19 +90,6 @@ internal interface ChannelDao { @Query("DELETE FROM $CHANNEL_ENTITY_TABLE_NAME") suspend fun deleteAll() - @Query( - "UPDATE $CHANNEL_ENTITY_TABLE_NAME " + - "SET oldestLoadedDate = :date " + - "WHERE cid = :cid", - ) - suspend fun updateOldestLoadedDate(cid: String, date: Date) - - @Query( - "SELECT oldestLoadedDate FROM $CHANNEL_ENTITY_TABLE_NAME " + - "WHERE cid = :cid", - ) - suspend fun selectOldestLoadedDate(cid: String): Date? - private companion object { private const val NO_LIMIT: Int = -1 } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt index 26cbcc25d08..b0ebc901c20 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt @@ -87,7 +87,7 @@ internal data class ChannelEntity( val membership: MemberEntity?, val activeLiveLocations: List, val messageCount: Int?, - val oldestLoadedDate: Date? = null, // Floor for local-only message window — written on onQueryChannelResult; read by Phase 2 DB-seed path + val oldestLoadedDate: Date? = null, ) { /** * The channel id in the format messaging:123. 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 2fbecdf4050..8f44ed39fc9 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 @@ -299,11 +299,4 @@ internal class DatabaseChannelRepository( override suspend fun clear() { dbMutex.withLock { channelDao.deleteAll() } } - - override suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) { - channelDao.updateOldestLoadedDate(cid, date) - } - - override suspend fun selectOldestLoadedDateForChannel(cid: String): Date? = - channelDao.selectOldestLoadedDate(cid) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt index 1cd9e3345f3..7e07d1c10bb 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/QueryChannelListenerState.kt @@ -53,8 +53,8 @@ internal class QueryChannelListenerState(private val logic: LogicRegistry) : Que ) { logger.d { "[onQueryChannelRequest] cid: $channelType:$channelId, request: $request" } logic.channel(channelType, channelId).apply { - updateStateFromDatabase(request) setPaginationDirection(request) + updateStateFromDatabase(request) } } 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 c6e858b05cd..4b518377b1f 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 @@ -41,7 +41,6 @@ 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 @@ -91,6 +90,7 @@ internal class ChannelLogicImpl( if (query.isFilteringMessages()) return // Populate from DB ONLY if loading latest messages val channel = fetchOfflineChannel(cid, query) ?: return + val localOnlyMessages = repository.selectLocalOnlyMessagesForChannel(cid) updateDataForChannel( channel = channel, messageLimit = query.messagesLimit(), @@ -100,30 +100,25 @@ internal class ChannelLogicImpl( isNotificationUpdate = query.isNotificationUpdate, isChannelsStateUpdate = true, ) + state.paginationManager.setOldestMessage(channel.messages.lastOrNull()) + state.setLocalOnlyMessages(localOnlyMessages) } override fun setPaginationDirection(query: QueryChannelRequest) { - when { - query.filteringOlderMessages() -> state.setLoadingOlderMessages(true) - query.isFilteringNewerMessages() -> state.setLoadingNewerMessages(true) - } + state.paginationManager.begin(query) } override fun onQueryChannelResult(query: QueryChannelRequest, result: Result) { + val limit = query.messagesLimit() + val isNotificationUpdate = query.isNotificationUpdate + // Update pagination/recovery state only if it's not a notification update + // (from LoadNotificationDataWorker) and a limit is set (otherwise we are not loading messages) + if (!isNotificationUpdate && limit != 0) { + state.paginationManager.end(query, result) + } when (result) { is Result.Success -> { - val limit = query.messagesLimit() val channel = result.value - val endReached = limit > channel.messages.size - val isNotificationUpdate = query.isNotificationUpdate - - // Update pagination/recovery state only if it's not a notification update - // (from LoadNotificationDataWorker) and a limit is set (otherwise we are not loading messages) - if (!isNotificationUpdate && limit != 0) { - state.setRecoveryNeeded(false) - updatePaginationEnd(query, endReached) - } - // Update channel data val channelData = channel.toChannelData() state.updateChannelData { @@ -144,28 +139,20 @@ internal class ChannelLogicImpl( state.setChannelConfig(channel.config) // Update messages if (limit > 0) { - coroutineScope.launch { - val localOnlyFromDb = repository.selectLocalOnlyMessagesForChannel(cid) - val windowFloor: Date? = channel.messages - .mapNotNull { it.getCreatedAtOrNull() } - .minOrNull() - updateMessages(query, channel, localOnlyFromDb, windowFloor) - } + updateMessages(query, channel) } // Add pinned messages state.addPinnedMessages(channel.pinnedMessages) - // Update loading states - state.setLoadingOlderMessages(false) - state.setLoadingNewerMessages(false) + // Reset recovery state + if (!isNotificationUpdate && limit != 0) { + state.setRecoveryNeeded(false) + } } is Result.Failure -> { // Mark the channel as needing recovery if the error is not permanent val isPermanent = result.value.isPermanent() state.setRecoveryNeeded(recoveryNeeded = !isPermanent) - // Reset loading states - state.setLoadingOlderMessages(false) - state.setLoadingNewerMessages(false) } } } @@ -180,7 +167,6 @@ internal class ChannelLogicImpl( } override suspend fun loadAfter(messageId: String, limit: Int): Result { - state.setLoadingNewerMessages(true) val request = QueryChannelPaginationRequest(limit) .apply { messageFilterValue = messageId @@ -191,7 +177,6 @@ internal class ChannelLogicImpl( } override suspend fun loadBefore(messageId: String?, limit: Int): Result { - state.setLoadingOlderMessages(true) val messageId = messageId ?: state.getOldestMessage()?.id val request = QueryChannelPaginationRequest(limit) .apply { @@ -313,27 +298,15 @@ internal class ChannelLogicImpl( state.setPendingMessages(channel.pendingMessages.map(PendingMessage::message)) // Update messages based on the relationship between the incoming page and existing state. if (messageLimit > 0) { - // Prefetch local-only messages and the persisted window floor so that preservation can - // re-inject any local-only messages that the server page does not include. - val localOnlyFromDb = repository.selectLocalOnlyMessagesForChannel(cid) - val persistedFloor: Date? = repository.selectOldestLoadedDateForChannel(cid) val sortedMessages = withContext(Dispatchers.Default) { channel.messages.sortedBy { it.getCreatedAtOrNull() } } val currentMessages = state.messages.value when { shouldRefreshMessages || currentMessages.isEmpty() -> { - if (isChannelsStateUpdate) { - // DB-seed path (updateStateFromDatabase → isChannelsStateUpdate=true): - // OfflinePlugin already includes local-only messages in the DB data. - // Full-replace is intentional here — preservation would double-inject them. - state.setMessages(sortedMessages) - } else { - // SyncManager reconnect path (isChannelsStateUpdate=false): - // Local-only messages are NOT in the server page; they must survive. - state.setMessagesPreservingLocalOnly(sortedMessages, localOnlyFromDb, persistedFloor) - } - state.setEndOfOlderMessages(channel.messages.size < messageLimit) + // Initial load (DB seed or first fetch) or explicit refresh — full replace + state.setMessages(sortedMessages) + state.paginationManager.setEndOfOlderMessages(channel.messages.size < messageLimit) } state.insideSearch.value -> { // User's window was already trimmed away from the latest (insideSearch set by @@ -347,21 +320,18 @@ internal class ChannelLogicImpl( // position as a mid-page: store the incoming as the "latest" cache and signal the UI. state.upsertCachedLatestMessages(sortedMessages) state.setInsideSearch(true) - state.setEndOfNewerMessages(false) + state.paginationManager.setEndOfNewerMessages(false) } else -> { // Incoming messages are contiguous with (or overlap) the current window. - // Preserve local-only messages that the server page does not include. - state.setMessagesPreservingLocalOnly(sortedMessages, localOnlyFromDb, persistedFloor) - state.setEndOfOlderMessages(channel.messages.size < messageLimit) + // Upsert preserves the user's scroll position while adding/updating messages. + state.upsertMessages(sortedMessages) + state.paginationManager.setEndOfOlderMessages(channel.messages.size < messageLimit) } } } // Add pinned messages state.addPinnedMessages(channel.pinnedMessages) - // Update loading states - state.setLoadingOlderMessages(false) - state.setLoadingNewerMessages(false) } override fun handleEvents(events: List) { @@ -375,104 +345,59 @@ internal class ChannelLogicImpl( } private suspend fun queryChannel(request: WatchChannelRequest): Result { + state.paginationManager.begin(request) val (type, id) = cid.cidToTypeAndId() return ChatClient.instance() .queryChannel(type, id, request, skipOnRequest = true) .await() } - private fun updatePaginationEnd(query: QueryChannelRequest, endReached: Boolean) { - when { - // Querying the newest messages (no pagination applied) - !query.isFilteringMessages() -> { - state.setEndOfOlderMessages(endReached) - state.setEndOfNewerMessages(true) - } - // Querying messages around a specific message - no way to know if we reached the end - query.isFilteringAroundIdMessages() -> { - state.setEndOfOlderMessages(false) - state.setEndOfNewerMessages(false) - } - // Querying older messages and reached the end - query.filteringOlderMessages() && endReached -> { - state.setEndOfOlderMessages(true) - } - // Querying newer messages and reached the end - query.isFilteringNewerMessages() && endReached -> { - state.setEndOfNewerMessages(true) - } - } - } - - private suspend fun updateMessages( - query: QueryChannelRequest, - channel: Channel, - localOnlyFromDb: List, - windowFloor: Date?, - ) { + private fun updateMessages(query: QueryChannelRequest, channel: Channel) { when { !query.isFilteringMessages() -> { // Loading newest messages (no pagination): // 1. Clear any cached latest messages (we are replacing the whole list) - // 2. Replace the active messages with the loaded ones, preserving local-only messages - // 3. No pending messages ceiling — we are at the latest messages + // 2. Replace the active messages with the loaded ones state.clearCachedLatestMessages() - state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) + state.setMessages(channel.messages) state.setInsideSearch(false) - state.setNewestLoadedDate(null) } query.isFilteringAroundIdMessages() -> { // Loading messages around a specific message: // 1. Cache the current messages (for access to latest messages) (unless already inside search) - // 2. Replace the active messages with the loaded ones, preserving local-only messages - // 3. Set ceiling to newest in loaded page — pending messages newer than the page are hidden + // 2. Replace the active messages with the loaded ones if (state.insideSearch.value) { // We are currently around a message, don't cache the latest messages, just replace the active set // Otherwise, the cached message set will wrongly hold the previous "around" set, instead of the // latest messages - state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) + state.setMessages(channel.messages) } else { // We are currently showing the latest messages, cache them first, then replace the active set state.cacheLatestMessages() - state.setMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) + state.setMessages(channel.messages) state.setInsideSearch(true) } - state.setNewestLoadedDate(channel.messages.lastOrNull()?.getCreatedAtOrNull()) } query.isFilteringNewerMessages() -> { - // Loading newer messages — merge new page into existing, preserve local-only within window + // Loading newer messages - upsert + state.upsertMessages(channel.messages) + state.trimOldestMessages() val endReached = query.messagesLimit() > channel.messages.size if (endReached) { - // Reached the latest messages — merge final page; keep the window floor (do NOT pass null, - // passing null would include all local-only regardless of position; floor stays at oldest loaded) - state.upsertMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) + // Reached the latest messages state.clearCachedLatestMessages() state.setInsideSearch(false) - state.setNewestLoadedDate(null) - } else { - // Still paginating toward latest — merge page, advance ceiling, preserve local-only within floor - state.upsertMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) - state.advanceNewestLoadedDate(channel.messages.lastOrNull()?.getCreatedAtOrNull()) } - state.trimOldestMessages() } query.filteringOlderMessages() -> { - // Loading older messages — merge old page into existing, preserve local-only within new floor - state.upsertMessagesPreservingLocalOnly(channel.messages, localOnlyFromDb, windowFloor) + // Loading older messages - prepend; ceiling does not change + state.upsertMessages(channel.messages) state.trimNewestMessages() } } - // Advance the oldest-loaded-date floor. Only queryChannel pagination (this path) should - // set this floor — updateDataForChannel (QueryChannels) must not, otherwise a channel-list - // preview would incorrectly filter out older pending messages. - state.advanceOldestLoadedDate(channel.messages) - // Persist window floor to DB so updateDataForChannel can read it on reconnect. - if (windowFloor != null) { - repository.updateOldestLoadedDateForChannel(cid, windowFloor) - } // Replace pending messages — server always returns the full latest set (up to 100, ASC). state.setPendingMessages(channel.pendingMessages.map { it.message }) } 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 971187a43cc..0c38583421b 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 @@ -37,6 +37,7 @@ import io.getstream.chat.android.client.internal.state.utils.internal.updateIf import io.getstream.chat.android.client.internal.state.utils.internal.upsertSorted import io.getstream.chat.android.client.internal.state.utils.internal.upsertSortedBounded import io.getstream.chat.android.client.utils.channel.calculateNewLastMessageAt +import io.getstream.chat.android.client.utils.message.isEphemeral import io.getstream.chat.android.extensions.lastMessageAt import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.ChannelData @@ -75,6 +76,7 @@ import kotlin.math.max * @property liveLocations A [StateFlow] providing the active live locations. * @property messageLimit The initial limit specifying how many of the visible messages should be kept in memory. If * null, no limit is enforced. + * @property paginationManager The [MessagesPaginationManager] handling the pagination state tracking. */ @Suppress("LargeClass", "LongParameterList", "TooManyFunctions") internal class ChannelStateImpl( @@ -85,6 +87,7 @@ internal class ChannelStateImpl( private val mutedUsers: StateFlow>, private val liveLocations: StateFlow>, private val messageLimit: Int?, + val paginationManager: MessagesPaginationManager = MessagesPaginationManagerImpl(), ) : ChannelState { override val cid: String = "$channelType:$channelId" @@ -93,8 +96,9 @@ internal class ChannelStateImpl( private val _repliedMessage = MutableStateFlow(null) private val _quotedMessagesMap = MutableStateFlow>>(emptyMap()) private val _messages = MutableStateFlow>(emptyList()) - - private val pendingMessagesManager = PendingMessagesManager() + private val localOnlyMessages = MutableStateFlow>(emptyList()) + private val _pendingEnabled = MutableStateFlow(false) + private val _pendingMessages = MutableStateFlow>(emptyList()) /** * Keeps track of the latest messages in the channel, if `Jump to message` was called, and a different, non-latest @@ -125,11 +129,7 @@ internal class ChannelStateImpl( private val _channelConfig = MutableStateFlow(Config()) // Non-channel states - private val _loading = MutableStateFlow(false) - private val _loadingOlderMessages = MutableStateFlow(false) - private val _loadingNewerMessages = MutableStateFlow(false) - private val _endOfOlderMessages = MutableStateFlow(false) - private val _endOfNewerMessages = MutableStateFlow(true) + private val _loading = paginationManager.state.mapState(MessagesPaginationState::isLoadingMessages) private var _recoveryNeeded = false private val _insideSearch = MutableStateFlow(false) private var lastStartTypingEvent: Date? = null @@ -146,6 +146,20 @@ internal class ChannelStateImpl( /* Keeps track of messages processed when updating the current user read state */ private val processedMessageIds = LruCache(maxSize = 100) + /* The local-only messages fitting into the currently loaded range */ + private val localOnlyMessagesInRange: StateFlow> = + combineStates(localOnlyMessages, paginationManager.state) { localOnly, pagination -> + if (localOnly.isEmpty()) return@combineStates emptyList() + localOnly.filter { pagination.isInWindow(it) } + } + + /* The pending messages fitting into the currently loaded range */ + private val pendingMessagesInRange: StateFlow> = + combineStates(_pendingEnabled, _pendingMessages, paginationManager.state) { enabled, pending, pagination -> + if (!enabled || pending.isEmpty()) return@combineStates emptyList() + pending.filter { pagination.isInWindow(it) } + } + private val logger by taggedLogger("ChannelStateImpl") override val repliedMessage: StateFlow = _repliedMessage.asStateFlow() @@ -156,17 +170,30 @@ internal class ChannelStateImpl( override val messages: StateFlow> = combineStates( _messages, - pendingMessagesManager.pendingMessagesInRange, - ) { regular, pending -> - if (pending.isEmpty()) return@combineStates regular - regular.mergeSorted(pending, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + pendingMessagesInRange, + localOnlyMessagesInRange, + ) { regular, pending, localOnly -> + // Pending and local-only are most often empty — skip merge entirely + when { + pending.isEmpty() && localOnly.isEmpty() -> regular + // Only one extra list is non-empty — single merge with regular + pending.isEmpty() -> + localOnly.mergeSorted(regular, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + localOnly.isEmpty() -> + pending.mergeSorted(regular, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + // Both non-empty — merge pending+localOnly first, then with regular + else -> + pending + .mergeSorted(localOnly, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + .mergeSorted(regular, idSelector = Message::id, comparator = MESSAGE_COMPARATOR) + } } override val pinnedMessages: StateFlow> = _pinnedMessages.asStateFlow() override val messagesState: StateFlow = combineStates(_loading, messages) { loading, messages -> when { - loading -> MessagesState.Loading + loading && messages.isEmpty() -> MessagesState.Loading messages.isEmpty() -> MessagesState.OfflineNoResults else -> MessagesState.Result(messages) } @@ -216,15 +243,19 @@ internal class ChannelStateImpl( override val muted: StateFlow = _muted.asStateFlow() - override val loading: StateFlow = _loading.asStateFlow() + override val loading: StateFlow = _loading - override val loadingOlderMessages: StateFlow = _loadingOlderMessages.asStateFlow() + override val loadingOlderMessages: StateFlow = + paginationManager.state.mapState(MessagesPaginationState::isLoadingPreviousMessages) - override val loadingNewerMessages: StateFlow = _loadingNewerMessages.asStateFlow() + override val loadingNewerMessages: StateFlow = + paginationManager.state.mapState(MessagesPaginationState::isLoadingNextMessages) - override val endOfOlderMessages: StateFlow = _endOfOlderMessages.asStateFlow() + override val endOfOlderMessages: StateFlow = + paginationManager.state.mapState(MessagesPaginationState::hasLoadedAllPreviousMessages) - override val endOfNewerMessages: StateFlow = _endOfNewerMessages.asStateFlow() + override val endOfNewerMessages: StateFlow = + paginationManager.state.mapState(MessagesPaginationState::hasLoadedAllNextMessages) override val recoveryNeeded: Boolean get() = _recoveryNeeded @@ -291,116 +322,6 @@ internal class ChannelStateImpl( _messages.value = messagesToSet } - /** - * Atomically merges [incoming] server messages with current local-only messages, - * applying the [windowFloor] filter, then writes to [_messages]. - * - * Use instead of [setMessages] when the source is a server response (onQueryChannelResult). - * Do NOT use for the DB-seed path ([updateDataForChannel] calls [setMessages] — DB data - * already includes local-only messages stored by OfflinePlugin). - * - * @param incoming Server message list from the API response. - * @param localOnlyFromDb Local-only messages fetched from DB before this call. - * Pass [emptyList()] when OfflinePlugin is absent — in-memory fallback is automatic. - * @param windowFloor Oldest [createdAt] in [incoming]; null when incoming is empty (no floor). - * Local-only messages with [createdAt] < [windowFloor] are excluded. - * TODO(Phase 2): When incoming is empty, read persisted floor from ChannelEntity instead. - */ - internal fun setMessagesPreservingLocalOnly( - incoming: List, - localOnlyFromDb: List, - windowFloor: Date?, - ) { - val incomingIds = incoming.map { it.id }.toSet() - - _messages.update { current -> - // Step 1: gather local-only from in-memory state (no-DB fallback path) - val fromState = current.filter { it.isLocalOnly() } - - // Step 2: union with DB-sourced local-only; deduplicate by id - val allLocalOnly = (fromState + localOnlyFromDb).distinctBy { it.id } - - // Step 3: server wins on ID collision — drop any local-only whose ID is - // already in the incoming list (server version in incoming replaces it) - val survivingLocalOnly = allLocalOnly.filter { localMsg -> - localMsg.id !in incomingIds - } - - // Step 4: apply window floor — exclude below-floor local-only messages. - // Null floor = first-ever open or empty page → include all local-only. - val inWindowLocalOnly = if (windowFloor == null) { - survivingLocalOnly - } else { - survivingLocalOnly.filter { msg -> - msg.getCreatedAtOrNull()?.let { d -> !d.before(windowFloor) } ?: true - } - } - - // Step 5: filter incoming through the existing shouldIgnoreUpsertion guard - // (handles shadowed users, thread-only messages — same as setMessages) - val validIncoming = incoming.filterNot { shouldIgnoreUpsertion(it) } - - // Step 6: replace — discard existing server messages, keep only incoming + local-only. - // Use upsertMessagesPreservingLocalOnly when merging a pagination page instead. - (validIncoming + inWindowLocalOnly) - .distinctBy { it.id } - .sortedWith(MESSAGE_COMPARATOR) - } - - // Step 7: sync quoted-message and poll indexes — mirrors setMessages lines 287-291. - // Must run AFTER _messages.update to operate on the final merged list. - for (message in _messages.value) { - message.replyTo?.let { addQuotedMessage(it.id, message.id) } - message.replyMessageId?.let { addQuotedMessage(it, message.id) } - message.poll?.let { registerPollForMessage(it, message.id) } - } - } - - /** - * Merges [incoming] server messages into the current message state, preserving existing - * server messages from prior pages and local-only messages within the window. - * - * Use for pagination branches ([filteringOlderMessages], [isFilteringNewerMessages]) where - * existing pages must be retained. For initial loads and around-message jumps, use - * [setMessagesPreservingLocalOnly] instead. - * - * @param incoming New page of server messages from the API response. - * @param localOnlyFromDb Local-only messages fetched from DB before this call. - * @param windowFloor Oldest [createdAt] in [incoming]; null to include all local-only. - */ - internal fun upsertMessagesPreservingLocalOnly( - incoming: List, - localOnlyFromDb: List, - windowFloor: Date?, - ) { - val incomingIds = incoming.map { it.id }.toSet() - - _messages.update { current -> - val fromState = current.filter { it.isLocalOnly() } - val allLocalOnly = (fromState + localOnlyFromDb).distinctBy { it.id } - val survivingLocalOnly = allLocalOnly.filter { it.id !in incomingIds } - val inWindowLocalOnly = if (windowFloor == null) { - survivingLocalOnly - } else { - survivingLocalOnly.filter { msg -> - msg.getCreatedAtOrNull()?.let { d -> !d.before(windowFloor) } ?: true - } - } - val validIncoming = incoming.filterNot { shouldIgnoreUpsertion(it) } - // Merge: keep existing server messages + add new page + surviving local-only - val existingServer = current.filterNot { it.isLocalOnly() } - (existingServer + validIncoming + inWindowLocalOnly) - .distinctBy { it.id } - .sortedWith(MESSAGE_COMPARATOR) - } - - for (message in _messages.value) { - message.replyTo?.let { addQuotedMessage(it.id, message.id) } - message.replyMessageId?.let { addQuotedMessage(it, message.id) } - message.poll?.let { registerPollForMessage(it, message.id) } - } - } - /** * Upserts a single message into the current state. * Uses optimized single-element upsert with binary search insertion. @@ -419,6 +340,16 @@ internal class ChannelStateImpl( comparator = MESSAGE_COMPARATOR, ) } + // Update can be called for "ephemeral" messages (ex. Shuffle Giphy) + if (message.isEphemeral()) { + localOnlyMessages.update { current -> + current.upsertSorted( + element = message, + idSelector = Message::id, + comparator = MESSAGE_COMPARATOR, + ) + } + } } /** @@ -508,6 +439,9 @@ internal class ChannelStateImpl( _pinnedMessages.update { current -> current.filterNot { id == it.id } } + localOnlyMessages.update { current -> + current.filterNot { id == it.id } + } } /** @@ -591,12 +525,21 @@ internal class ChannelStateImpl( } /** - * Retrieves the oldest (non-pending) message. + * Retrieves the oldest (non-pending, non-local-only) message. */ fun getOldestMessage(): Message? { return _messages.value.firstOrNull() } + /** + * Sets the state for the local-only messages. + * + * @param messages The local-only list of messages. + */ + fun setLocalOnlyMessages(messages: List) { + localOnlyMessages.update { messages } + } + // endregion // region PendingMessages @@ -606,33 +549,19 @@ internal class ChannelStateImpl( * channel response returns the latest 100 pending messages sorted by createdAt ASC, so we * always replace rather than merge. */ - fun setPendingMessages(messages: List) = pendingMessagesManager.setPendingMessages(messages) + fun setPendingMessages(messages: List) { + _pendingMessages.value = messages + } /** * Removes a single pending message by [id]. Called when a pending message is promoted to a * regular message (message.new event) or deleted (message.deleted event). */ - fun removePendingMessage(id: String) = pendingMessagesManager.removePendingMessage(id) - - /** - * Advances the oldest-loaded-date floor to the oldest date in [messages] if it is earlier - * than the current floor. The floor only ever moves backward — must only be called from - * paginated channel queries, never from a full channel data update. - */ - fun advanceOldestLoadedDate(messages: List) = pendingMessagesManager.advanceOldestLoadedDate(messages) - - /** - * Sets the newest-loaded-date ceiling to [date]. Pass `null` to remove the ceiling, i.e. - * when the user is viewing the latest messages. Set to the newest message date when jumping - * to a specific message so that newer pending messages are hidden. - */ - fun setNewestLoadedDate(date: Date?) = pendingMessagesManager.setNewestLoadedDate(date) - - /** - * Advances the newest-loaded-date ceiling forward if [date] is more recent than the current - * ceiling. Used when paginating toward newer messages while still not at the latest page. - */ - fun advanceNewestLoadedDate(date: Date?) = pendingMessagesManager.advanceNewestLoadedDate(date) + fun removePendingMessage(id: String) { + _pendingMessages.update { current -> + if (current.none { it.id == id }) current else current.filterNot { it.id == id } + } + } // endregion @@ -1288,7 +1217,9 @@ internal class ChannelStateImpl( */ fun setChannelConfig(config: Config) { _channelConfig.value = config - pendingMessagesManager.setEnabled(config.markMessagesPending) + val enabled = config.markMessagesPending + if (!enabled) _pendingMessages.value = emptyList() + _pendingEnabled.value = enabled } // endregion @@ -1388,51 +1319,6 @@ internal class ChannelStateImpl( // region NonChannelStates - /** - * Sets the loading state. - * - * @param loading `true` if loading, `false` otherwise. - */ - fun setLoading(loading: Boolean) { - _loading.value = loading - } - - /** - * Sets the loading older messages state. - * - * @param loadingOlderMessages `true` if loading older messages, `false` otherwise. - */ - fun setLoadingOlderMessages(loadingOlderMessages: Boolean) { - _loadingOlderMessages.value = loadingOlderMessages - } - - /** - * Sets the loading newer messages state. - * - * @param loadingNewerMessages `true` if loading newer messages, `false` otherwise. - */ - fun setLoadingNewerMessages(loadingNewerMessages: Boolean) { - _loadingNewerMessages.value = loadingNewerMessages - } - - /** - * Sets the end of older messages state. - * - * @param endOfOlderMessages `true` if there are no more older messages to load, `false` otherwise. - */ - fun setEndOfOlderMessages(endOfOlderMessages: Boolean) { - _endOfOlderMessages.value = endOfOlderMessages - } - - /** - * Sets the end of newer messages state. - * - * @param endOfNewerMessages `true` if there are no more newer messages to load, `false` otherwise. - */ - fun setEndOfNewerMessages(endOfNewerMessages: Boolean) { - _endOfNewerMessages.value = endOfNewerMessages - } - /** * Trims messages from the oldest end if the limit is exceeded. * Call after loading newer messages or receiving new messages via WebSocket while at the end of the list. @@ -1471,14 +1357,16 @@ internal class ChannelStateImpl( when (direction) { TrimDirection.FROM_OLDEST -> { _messages.update { it.takeLast(limit) } - _endOfOlderMessages.value = false + paginationManager.setEndOfOlderMessages(false) + paginationManager.setOldestMessage(_messages.value.firstOrNull()) } TrimDirection.FROM_NEWEST -> { // Cache the latest messages before trimming to preserve them for later cacheLatestMessages() _messages.update { it.take(limit) } - _endOfNewerMessages.value = false + paginationManager.setEndOfNewerMessages(false) + paginationManager.setNewestMessage(_messages.value.lastOrNull()) _insideSearch.value = true } } @@ -1555,7 +1443,8 @@ internal class ChannelStateImpl( _repliedMessage.value = null _quotedMessagesMap.value = emptyMap() _messages.value = emptyList() - pendingMessagesManager.reset() + _pendingMessages.value = emptyList() + _pendingEnabled.value = false _cachedLatestMessages.value = emptyList() _pinnedMessages.value = emptyList() _oldMessages.value = emptyList() @@ -1581,11 +1470,7 @@ internal class ChannelStateImpl( _channelConfig.value = Config() // Non-channel states - _loading.value = false - _loadingOlderMessages.value = false - _loadingNewerMessages.value = false - _endOfOlderMessages.value = false - _endOfNewerMessages.value = true + paginationManager.reset() _recoveryNeeded = false _insideSearch.value = false lastStartTypingEvent = null diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageLocalOnlyExt.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageLocalOnlyExt.kt deleted file mode 100644 index 9fc097698be..00000000000 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageLocalOnlyExt.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * 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.Message -import io.getstream.chat.android.models.MessageType -import io.getstream.chat.android.models.SyncStatus - -/** - * Returns true if this message is local-only and must be preserved across server message - * window replacements. Local-only messages are never returned by the server after the - * initial send attempt completes. - * - * Covers: - * - Pending sends: SYNC_NEEDED, IN_PROGRESS - * - Attachment upload in-flight: AWAITING_ATTACHMENTS - * - Send failed: FAILED_PERMANENTLY (user must see to retry or dismiss) - * - Ephemeral: type == "ephemeral" (e.g. Giphy previews — server never returns these) - * - Error type: type == "error" (client-generated, not re-delivered by server) - * - * DOES NOT include COMPLETED messages — those are already in the server response. - */ -internal fun Message.isLocalOnly(): Boolean = - syncStatus in setOf( - SyncStatus.SYNC_NEEDED, // new message or pending edit/delete - SyncStatus.IN_PROGRESS, // send in flight - SyncStatus.AWAITING_ATTACHMENTS, // attachment upload pending - SyncStatus.FAILED_PERMANENTLY, // permanent failure — user must see to retry - ) || type == MessageType.EPHEMERAL // Giphy preview etc. — never server-returned - || type == MessageType.ERROR // error type — not re-delivered by server diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt new file mode 100644 index 00000000000..2d35d628fde --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt @@ -0,0 +1,263 @@ +/* + * 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.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault +import io.getstream.chat.android.client.extensions.internal.NEVER +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.Message +import io.getstream.result.Result +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.Date + +/** + * Keeps track of the current channel messages pagination state. + * + * @param oldestMessage The oldest fetched message while paginating. + * @param newestMessage The newest fetched message while paginating. + * @param hasLoadedAllNextMessages Indicator whether the newest messages have all been loaded. If false, it means the + * channel is currently in a mid-page. + * @param hasLoadedAllPreviousMessages Indicator whether the oldest messages have been loaded. + * @param isLoadingNextMessages Indicator whether the channel is currently loading next (newer) messages. + * @param isLoadingPreviousMessages Indicator whether the channel is currently loading previous (older) messages. + * @param isLoadingMiddleMessages Indicator whether the channel is currently loading a page around a message. + */ +internal data class MessagesPaginationState( + val oldestMessage: Message? = null, + val newestMessage: Message? = null, + val hasLoadedAllNextMessages: Boolean = true, + val hasLoadedAllPreviousMessages: Boolean = false, + val isLoadingNextMessages: Boolean = false, + val isLoadingPreviousMessages: Boolean = false, + val isLoadingMiddleMessages: Boolean = false, +) { + + /** + * Indicator whether the channel is currently loading messages on either previous, middle or next pages. + */ + val isLoadingMessages: Boolean + get() = isLoadingNextMessages || isLoadingPreviousMessages || isLoadingMiddleMessages + + /** + * Indicator if the channel is currently mid-page. + */ + val isJumpingToMessage: Boolean + get() = !hasLoadedAllNextMessages + + /** + * The oldest fetched message createdAt date while paginating. + */ + val oldestMessageAt: Date? + get() = oldestMessage?.createdAt + + /** + * The newest fetched message createdAt date while paginating. + */ + val newestMessageAt: Date? + get() = newestMessage?.createdAt + + /** + * Returns true if [Message] falls within the currently loaded pagination window. + * + * The floor is [oldestMessageAt] (null = no floor). The ceiling is [newestMessageAt] (null = at + * the latest page, no ceiling). Both properties are null-when-unbounded, so no additional + * flag check is required. + */ + fun isInWindow(message: Message): Boolean { + val date = message.getCreatedAtOrDefault(NEVER) + return (oldestMessageAt == null || date >= oldestMessageAt) && + (newestMessageAt == null || date <= newestMessageAt) + } +} + +/** + * State manager for the channel pagination state. + */ +internal interface MessagesPaginationManager { + + /** + * The current state of the messages pagination. + */ + val state: StateFlow + + /** + * Called whenever a pagination call is about to happen. + * + * @param query The pagination request. + */ + fun begin(query: QueryChannelRequest) + + /** + * Called whenever a pagination call has finished. + * + * @param query The pagination request. + * @param result The pagination result. + */ + fun end(query: QueryChannelRequest, result: Result) + + /** + * Sets the oldest [message] to the pagination state. + */ + fun setOldestMessage(message: Message?) + + /** + * Sets the newest [message] (ceiling) in the pagination state. + * Pass null to indicate the latest page has no ceiling. + */ + fun setNewestMessage(message: Message?) + + /** + * Sets whether all older (previous) messages have been loaded. + */ + fun setEndOfOlderMessages(hasLoadedAll: Boolean) + + /** + * Sets whether all newer (next) messages have been loaded. + * When [hasLoadedAll] is true, the newest-message ceiling is cleared. + */ + fun setEndOfNewerMessages(hasLoadedAll: Boolean) + + /** + * Resets pagination state back to its initial defaults. + */ + fun reset() +} + +/** + * Default implementation of the [MessagesPaginationManager]. + */ +internal class MessagesPaginationManagerImpl : MessagesPaginationManager { + + private val _state: MutableStateFlow = MutableStateFlow(MessagesPaginationState()) + + override val state: StateFlow + get() = _state.asStateFlow() + + override fun begin(query: QueryChannelRequest) { + val current = _state.value + val new = when { + query.filteringOlderMessages() -> { + current.copy(isLoadingPreviousMessages = true) + } + query.isFilteringNewerMessages() -> { + current.copy(isLoadingNextMessages = true) + } + query.isFilteringAroundIdMessages() -> { + current.copy(isLoadingMiddleMessages = true, hasLoadedAllNextMessages = false) + } + else -> { + MessagesPaginationState() + } + } + _state.update { new } + } + + override fun end(query: QueryChannelRequest, result: Result) { + // Failure + if (result is Result.Failure) { + _state.update { current -> + current.copy( + isLoadingNextMessages = false, + isLoadingPreviousMessages = false, + isLoadingMiddleMessages = false, + ) + } + return + } + // Success + result as Result.Success + val current = _state.value + val messages = result.value.messages + val oldestMessage = messages.firstOrNull() + val newestMessage = messages.lastOrNull() + val new = when { + // Loading older + query.filteringOlderMessages() -> { + val hasLoadedAllPreviousMessages = messages.size < query.messagesLimit() + current.copy( + oldestMessage = oldestMessage, + hasLoadedAllPreviousMessages = hasLoadedAllPreviousMessages, + isLoadingNextMessages = false, + isLoadingPreviousMessages = false, + isLoadingMiddleMessages = false, + ) + } + // Loading newer + query.isFilteringNewerMessages() -> { + val hasLoadedAllNextMessages = messages.size < query.messagesLimit() + current.copy( + newestMessage = if (hasLoadedAllNextMessages) null else newestMessage, + hasLoadedAllNextMessages = hasLoadedAllNextMessages, + isLoadingNextMessages = false, + isLoadingPreviousMessages = false, + isLoadingMiddleMessages = false, + ) + } + // Loading around + query.isFilteringAroundIdMessages() -> { + current.copy( + oldestMessage = oldestMessage, + newestMessage = newestMessage, + hasLoadedAllNextMessages = false, + hasLoadedAllPreviousMessages = false, + isLoadingNextMessages = false, + isLoadingPreviousMessages = false, + isLoadingMiddleMessages = false, + ) + } + // Else - no pagination + else -> { + current.copy( + oldestMessage = oldestMessage, + newestMessage = null, + hasLoadedAllNextMessages = true, + hasLoadedAllPreviousMessages = messages.size < query.messagesLimit(), + ) + } + } + _state.update { new } + } + + override fun setOldestMessage(message: Message?) { + _state.update { it.copy(oldestMessage = message) } + } + + override fun setNewestMessage(message: Message?) { + _state.update { it.copy(newestMessage = message) } + } + + override fun setEndOfOlderMessages(hasLoadedAll: Boolean) { + _state.update { it.copy(hasLoadedAllPreviousMessages = hasLoadedAll) } + } + + override fun setEndOfNewerMessages(hasLoadedAll: Boolean) { + _state.update { current -> + current.copy( + hasLoadedAllNextMessages = hasLoadedAll, + newestMessage = if (hasLoadedAll) null else current.newestMessage, + ) + } + } + + override fun reset() { + _state.value = MessagesPaginationState() + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManager.kt deleted file mode 100644 index 58f21ec27f1..00000000000 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManager.kt +++ /dev/null @@ -1,141 +0,0 @@ -/* - * 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.client.extensions.getCreatedAtOrNull -import io.getstream.chat.android.client.internal.state.utils.internal.combineStates -import io.getstream.chat.android.models.Message -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.update -import java.util.Date - -/** - * Encapsulates all pending-message state and logic for a channel. - * - * Pending messages are messages awaiting moderation approval, kept separate from regular messages - * and merged into the public message list at their natural position in the timeline. - * - * The feature is gated by [setEnabled]: when disabled, [pendingMessagesInRange] always emits an - * empty list and all buffered state is cleared. - */ -internal class PendingMessagesManager { - - private val _enabled = MutableStateFlow(false) - private val _pendingMessages = MutableStateFlow>(emptyList()) - private val _dateRange = MutableStateFlow(DateRange(oldest = null, newest = null)) - - /** - * Filtered pending messages ready for merging into the regular message list. - * Returns an empty list when disabled or no pending messages fall within the loaded window. - */ - val pendingMessagesInRange: StateFlow> = combineStates( - _enabled, - _pendingMessages, - _dateRange, - ) { enabled, pending, range -> - if (!enabled || pending.isEmpty()) return@combineStates emptyList() - pending.filter { msg -> - val date = msg.getCreatedAtOrNull() ?: Date(0) - (range.oldest == null || date >= range.oldest) && - (range.newest == null || date <= range.newest) - } - } - - /** - * Enables or disables the pending-messages feature. Clears all buffered state before - * disabling so no stale data leaks through [pendingMessagesInRange]. - */ - fun setEnabled(enabled: Boolean) { - if (!enabled) clear() - _enabled.value = enabled - } - - /** - * Replaces the pending messages list. The server is authoritative — every channel response - * returns the latest 100 pending messages sorted by createdAt ASC, so we always replace. - */ - fun setPendingMessages(messages: List) { - _pendingMessages.value = messages.sortedWith(MESSAGE_COMPARATOR) - } - - /** - * Removes a single pending message by ID. Called when a pending message is promoted to a - * regular message (message.new event) or deleted (message.deleted event). - */ - fun removePendingMessage(id: String) { - _pendingMessages.update { current -> - if (current.none { it.id == id }) { - current - } else { - current.filterNot { it.id == id } - } - } - } - - /** - * Advances the floor of the date range to the oldest message date if it is older than the - * current floor. The floor only ever moves backward. - */ - fun advanceOldestLoadedDate(messages: List) { - val newOldest = messages.firstOrNull()?.getCreatedAtOrNull() ?: return - _dateRange.update { current -> - if (current.oldest == null || newOldest < current.oldest) { - current.copy(oldest = newOldest) - } else { - current - } - } - } - - /** - * Sets the ceiling of the date range to the given date. Pass null to remove the ceiling - * (i.e. when viewing the latest messages). - */ - fun setNewestLoadedDate(date: Date?) { - _dateRange.update { it.copy(newest = date) } - } - - /** - * Advances the ceiling of the date range forward if [date] is newer than the current ceiling. - * Used when loading newer pages while still not at the latest messages. - */ - fun advanceNewestLoadedDate(date: Date?) { - if (date == null) return - _dateRange.update { current -> - if (current.newest == null || date > current.newest) { - current.copy(newest = date) - } else { - current - } - } - } - - /** Clears all buffered state. Called by [setEnabled] when disabling and by [ChannelStateImpl.destroy]. */ - fun reset() = clear() - - private fun clear() { - _pendingMessages.value = emptyList() - _dateRange.value = DateRange(oldest = null, newest = null) - } - - private data class DateRange(val oldest: Date?, val newest: Date?) - - private companion object { - private val MESSAGE_COMPARATOR: Comparator = compareBy { it.getCreatedAtOrNull() } - } -} 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 40325678162..0b3d4b7e6e7 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 @@ -165,19 +165,6 @@ public interface ChannelRepository { */ public suspend fun clear() - /** - * Updates only the [oldestLoadedDate] field for [cid] in the channel entity. - * Used to persist the window floor after onQueryChannelResult completes. - * Phase 2 reads this value at DB-seed time to apply the floor without a network response. - */ - public suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) - - /** - * Reads the persisted window floor (oldest loaded date) for [cid] from the channel entity. - * Returns null if the channel entity does not exist or no floor has been persisted yet. - */ - public suspend fun selectOldestLoadedDateForChannel(cid: String): Date? - private companion object { private const val NO_LIMIT: Int = -1 } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt index 2986f1cdbf5..e29bfecccb4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/noop/NoOpChannelRepository.kt @@ -44,12 +44,6 @@ internal object NoOpChannelRepository : ChannelRepository { override suspend fun selectMembersForChannel(cid: String): List = emptyList() override suspend fun updateMembersForChannel(cid: String, members: List) { /* No-Op */ } override suspend fun updateLastMessageForChannel(cid: String, lastMessage: Message) { /* No-Op */ } - override suspend fun evictChannel(cid: String) { /* No-Op */ } - override suspend fun clear() { /* No-Op */ } - - override suspend fun updateOldestLoadedDateForChannel(cid: String, date: Date) = Unit - - override suspend fun selectOldestLoadedDateForChannel(cid: String): Date? = null } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt index 6ff38793da3..1e283c7d8e2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt @@ -224,3 +224,26 @@ internal fun DraftMessage.ensureId(): DraftMessage = * Generates a fallback message id (lowercase UUID). */ internal fun fallbackMessageId(): String = UUID.randomUUID().toString().lowercase() + +/** + * Returns true if this message is local-only and must be preserved across server message + * window replacements. Local-only messages are never returned by the server after the + * initial send attempt completes. + * + * Covers: + * - Pending sends: SYNC_NEEDED, IN_PROGRESS + * - Attachment upload in-flight: AWAITING_ATTACHMENTS + * - Send failed: FAILED_PERMANENTLY (user must see to retry or dismiss) + * - Ephemeral: type == "ephemeral" (e.g. Giphy previews — not re-delivered by server) + * - Error type: type == "error" (client-generated, not re-delivered by server) + * + * DOES NOT include COMPLETED messages — those are already in the server response. + */ +internal fun Message.isLocalOnly(): Boolean = + syncStatus in setOf( + SyncStatus.SYNC_NEEDED, // new message or pending edit/delete + SyncStatus.IN_PROGRESS, // send in flight + SyncStatus.AWAITING_ATTACHMENTS, // attachment upload pending + SyncStatus.FAILED_PERMANENTLY, // permanent failure — user must see to retry + ) || type == MessageType.EPHEMERAL || // Giphy preview etc. — not re-delivered by server + type == MessageType.ERROR // error type — not re-delivered by server diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/attachment/UploadAttachmentsIntegrationTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/attachment/UploadAttachmentsIntegrationTests.kt index a3b2d662136..21662d9b082 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/attachment/UploadAttachmentsIntegrationTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/channel/controller/attachment/UploadAttachmentsIntegrationTests.kt @@ -309,6 +309,8 @@ internal class MockMessageRepository : MessageRepository { // No-op } + override suspend fun selectLocalOnlyMessagesForChannel(cid: String): List = emptyList() + override suspend fun clear() { messages.clear() } 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 b63985b5e9e..05427add8b2 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.MessagesPaginationManager 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.ChannelData @@ -48,9 +49,7 @@ import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.atLeastOnce -import org.mockito.kotlin.isNull import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify @@ -66,6 +65,8 @@ internal class ChannelLogicImplTest { val testCoroutines = TestCoroutineExtension() } + private lateinit var paginationManager: MessagesPaginationManager + private lateinit var stateImpl: ChannelStateImpl private lateinit var repository: RepositoryFacade private lateinit var mutableGlobalState: MutableGlobalState @@ -77,6 +78,7 @@ internal class ChannelLogicImplTest { @BeforeEach fun setUp() { + paginationManager = mock() stateImpl = mock() repository = mock() mutableGlobalState = mock() @@ -88,6 +90,7 @@ internal class ChannelLogicImplTest { whenever(stateImpl.channelConfig).thenReturn(MutableStateFlow(Config())) whenever(stateImpl.messages).thenReturn(MutableStateFlow(emptyList())) whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(false)) + whenever(stateImpl.paginationManager).thenReturn(paginationManager) // Stub global state whenever(mutableGlobalState.channelMutes).thenReturn(MutableStateFlow(emptyList())) @@ -129,36 +132,13 @@ internal class ChannelLogicImplTest { inner class SetPaginationDirection { @Test - fun `should set loading older messages when filtering older messages`() { + fun `setPaginationDirection delegates to MessagesPaginationManager`() { // Given val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30) // When sut.setPaginationDirection(query) // Then - verify(stateImpl).setLoadingOlderMessages(true) - verify(stateImpl, never()).setLoadingNewerMessages(true) - } - - @Test - fun `should set loading newer messages when filtering newer messages`() { - // Given - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30) - // When - sut.setPaginationDirection(query) - // Then - verify(stateImpl).setLoadingNewerMessages(true) - verify(stateImpl, never()).setLoadingOlderMessages(true) - } - - @Test - fun `should not set any loading state when not filtering messages`() { - // Given - val query = QueryChannelRequest().withMessages(30) - // When - sut.setPaginationDirection(query) - // Then - verify(stateImpl, never()).setLoadingOlderMessages(true) - verify(stateImpl, never()).setLoadingNewerMessages(true) + verify(paginationManager).begin(query) } } @@ -393,11 +373,11 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) val query = QueryChannelRequest().withMessages(30) + val result = Result.Success(channel) // When - sut.onQueryChannelResult(query, Result.Success(channel)) + sut.onQueryChannelResult(query, result) // Then - verify(stateImpl).setLoadingOlderMessages(false) - verify(stateImpl).setLoadingNewerMessages(false) + verify(paginationManager).end(query, result) } @Test @@ -419,7 +399,6 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl, never()).setMessages(any()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).clearCachedLatestMessages() verify(stateImpl, never()).setInsideSearch(any()) @@ -428,122 +407,6 @@ internal class ChannelLogicImplTest { // endregion - // region onQueryChannelResult - Success - Pagination end - - @Nested - inner class OnQueryChannelResultPaginationEnd { - - @Test - fun `should set end of older messages when loading latest and end reached`() { - // Given (limit=30, messages.size=10, so endReached=true) - val messages = (1..10).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfOlderMessages(true) - verify(stateImpl).setEndOfNewerMessages(true) - } - - @Test - fun `should not set end of older messages when loading latest and not reached`() { - // Given (limit=10, messages.size=10, so endReached=false) - val messages = (1..10).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(10) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfOlderMessages(false) - verify(stateImpl).setEndOfNewerMessages(true) - } - - @Test - fun `should set both ends to false when filtering around id`() { - // Given - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "m3", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfOlderMessages(false) - verify(stateImpl).setEndOfNewerMessages(false) - } - - @Test - fun `should set end of older messages when filtering older and end reached`() { - // Given (limit=30, messages.size=5, so endReached=true) - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m10", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfOlderMessages(true) - } - - @Test - fun `should set end of newer messages when filtering newer and end reached`() { - // Given (limit=30, messages.size=5, so endReached=true) - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m1", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setEndOfNewerMessages(true) - } - } - - // endregion - // region onQueryChannelResult - Success - Message updates @Nested @@ -568,7 +431,7 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl).clearCachedLatestMessages() - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl).setMessages(messages) verify(stateImpl).setInsideSearch(false) } @@ -592,7 +455,7 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl).cacheLatestMessages() - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl).setMessages(messages) verify(stateImpl).setInsideSearch(true) } @@ -616,11 +479,11 @@ internal class ChannelLogicImplTest { sut.onQueryChannelResult(query, Result.Success(channel)) // Then verify(stateImpl, never()).cacheLatestMessages() - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl).setMessages(messages) } @Test - fun `should preserve local-only and trim oldest when loading newer messages`() { + fun `should upsert messages and trim oldest when loading newer messages`() { // Given — limit=30, messages.size=30 → endReached=false val messages = (1..30).map { randomMessage(id = "m$it") } val channel = randomChannel( @@ -636,8 +499,7 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — pagination merges (not replaces); trim still applied - verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + verify(stateImpl).upsertMessages(messages) verify(stateImpl).trimOldestMessages() } @@ -686,7 +548,7 @@ internal class ChannelLogicImplTest { } @Test - fun `should preserve local-only and trim newest when loading older messages`() { + fun `should upsert messages and trim newest when loading older messages`() { // Given val messages = (1..10).map { randomMessage(id = "m$it") } val channel = randomChannel( @@ -702,8 +564,8 @@ internal class ChannelLogicImplTest { val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m20", 30) // When sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — pagination merges (not replaces); trim still applied - verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) + // Then + verify(stateImpl).upsertMessages(messages) verify(stateImpl).trimNewestMessages() } } @@ -746,11 +608,11 @@ internal class ChannelLogicImplTest { // Given val error = Error.GenericError("Error") val query = QueryChannelRequest().withMessages(30) + val result = Result.Failure(error) // When - sut.onQueryChannelResult(query, Result.Failure(error)) + sut.onQueryChannelResult(query, result) // Then - verify(stateImpl).setLoadingOlderMessages(false) - verify(stateImpl).setLoadingNewerMessages(false) + verify(paginationManager).end(query, result) } } @@ -1245,9 +1107,8 @@ internal class ChannelLogicImplTest { } @Test - fun `should set messages sorted by createdAt when messageLimit is positive (DB-seed path)`() = runTest { + fun `should set messages sorted by createdAt when messageLimit is positive`() = runTest { // Given - messages in descending order (as returned by the DB query) - // isChannelsStateUpdate=true simulates the DB-seed path where setMessages is required val olderMessage = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) val newerMessage = randomMessage(id = "m2", createdAt = Date(2000L), createdLocallyAt = null) val channel = randomChannel( @@ -1261,7 +1122,7 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) // When - sut.updateDataForChannel(channel = channel, messageLimit = 30, isChannelsStateUpdate = true) + sut.updateDataForChannel(channel = channel, messageLimit = 30) // Then - messages must be sorted ascending before being set into state verify(stateImpl).setMessages(listOf(olderMessage, newerMessage)) } @@ -1283,7 +1144,7 @@ internal class ChannelLogicImplTest { // When sut.updateDataForChannel(channel = channel, messageLimit = 30) // Then - verify(stateImpl).setEndOfOlderMessages(true) + verify(paginationManager).setEndOfOlderMessages(true) } @Test @@ -1303,7 +1164,7 @@ internal class ChannelLogicImplTest { // When sut.updateDataForChannel(channel = channel, messageLimit = 2) // Then - verify(stateImpl).setEndOfOlderMessages(false) + verify(paginationManager).setEndOfOlderMessages(false) } @Test @@ -1324,7 +1185,7 @@ internal class ChannelLogicImplTest { sut.updateDataForChannel(channel = channel, messageLimit = 0) // Then verify(stateImpl, never()).setMessages(any()) - verify(stateImpl, never()).setEndOfOlderMessages(any()) + verify(paginationManager, never()).setEndOfOlderMessages(any()) } @Test @@ -1347,26 +1208,6 @@ internal class ChannelLogicImplTest { verify(stateImpl).addPinnedMessages(pinnedMessages) } - @Test - fun `should reset loading states`() = runTest { - // Given - val channel = randomChannel( - id = "123", - type = "messaging", - messages = emptyList(), - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - // When - sut.updateDataForChannel(channel = channel, messageLimit = 0) - // Then - verify(stateImpl).setLoadingOlderMessages(false) - verify(stateImpl).setLoadingNewerMessages(false) - } - @Test fun `should set pending messages from channel response`() = runTest { // Given @@ -1390,7 +1231,7 @@ internal class ChannelLogicImplTest { } @Test - fun `should preserve local-only when state has messages and incoming are contiguous`() = runTest { + fun `should upsert messages when state has messages and incoming are contiguous`() = runTest { val existingMsg = randomMessage(id = "existing", createdAt = Date(1000L), createdLocallyAt = null) whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) val incomingMsg = randomMessage(id = "new", createdAt = Date(500L), createdLocallyAt = null) @@ -1405,12 +1246,10 @@ internal class ChannelLogicImplTest { watcherCount = 0, ) sut.updateDataForChannel(channel = channel, messageLimit = 30) - // else branch now uses preservation instead of plain upsert - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) + verify(stateImpl).upsertMessages(listOf(incomingMsg)) verify(stateImpl, never()).setMessages(any()) verify(stateImpl, never()).upsertCachedLatestMessages(any()) - verify(stateImpl, never()).setEndOfNewerMessages(any()) + verify(paginationManager, never()).setEndOfNewerMessages(any()) } @Test @@ -1431,10 +1270,10 @@ internal class ChannelLogicImplTest { sut.updateDataForChannel(channel = channel, messageLimit = 30) verify(stateImpl).upsertCachedLatestMessages(listOf(incomingMsg)) verify(stateImpl).setInsideSearch(true) - verify(stateImpl).setEndOfNewerMessages(false) + verify(paginationManager).setEndOfNewerMessages(false) verify(stateImpl, never()).setMessages(any()) verify(stateImpl, never()).upsertMessages(any()) - verify(stateImpl, never()).setEndOfOlderMessages(any()) + verify(paginationManager, never()).setEndOfOlderMessages(any()) } @Test @@ -1458,12 +1297,11 @@ internal class ChannelLogicImplTest { verify(stateImpl, never()).setMessages(any()) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).setInsideSearch(any()) - verify(stateImpl, never()).setEndOfNewerMessages(any()) + verify(paginationManager, never()).setEndOfNewerMessages(any()) } @Test - fun `should replace messages when shouldRefreshMessages is true and isChannelsStateUpdate is true`() = runTest { - // DB-seed path: isChannelsStateUpdate=true means OfflinePlugin already includes local-only in DB data + fun `should replace messages when shouldRefreshMessages is true regardless of existing state`() = runTest { val existingMsg = randomMessage(id = "old", createdAt = Date(1000L), createdLocallyAt = null) whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) @@ -1477,16 +1315,10 @@ internal class ChannelLogicImplTest { memberCount = 0, watcherCount = 0, ) - sut.updateDataForChannel( - channel = channel, - messageLimit = 30, - shouldRefreshMessages = true, - isChannelsStateUpdate = true, - ) + sut.updateDataForChannel(channel = channel, messageLimit = 30, shouldRefreshMessages = true) verify(stateImpl).setMessages(listOf(incomingMsg)) verify(stateImpl, never()).upsertMessages(any()) verify(stateImpl, never()).upsertCachedLatestMessages(any()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) } } @@ -1518,551 +1350,6 @@ internal class ChannelLogicImplTest { // Then verify(stateImpl, atLeastOnce()).setPendingMessages(listOf(pendingMsg)) } - - @Test - fun `should call setNewestLoadedDate with null when loading latest messages`() { - // Given - val channel = randomChannel( - id = "123", - type = "messaging", - messages = emptyList(), - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - val query = QueryChannelRequest().withMessages(30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setNewestLoadedDate(null) - } - - @Test - fun `should call setNewestLoadedDate with newest message date when loading around id`() { - // Given - whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(false)) - val newestDate = Date(2000L) - val messages = listOf( - randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null), - randomMessage(id = "m2", createdAt = newestDate, createdLocallyAt = null), - ) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "m1", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setNewestLoadedDate(newestDate) - } - - @Test - fun `should call setNewestLoadedDate with null when paginating newer reaches the end`() { - // Given (limit=30, messages.size=5, so endReached=true) - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setNewestLoadedDate(null) - } - - @Test - fun `should call advanceNewestLoadedDate when paginating newer and not at end`() { - // Given (limit=5, messages.size=5, so endReached=false) - val newestDate = Date(5000L) - val messages = (1..5).map { i -> - randomMessage(id = "m$i", createdAt = Date(i * 1000L), createdLocallyAt = null) - } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 5) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).advanceNewestLoadedDate(newestDate) - } - - @Test - fun `should call advanceOldestLoadedDate with channel messages when loading latest`() { - // Given - val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - val query = QueryChannelRequest().withMessages(30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).advanceOldestLoadedDate(messages) - } - - @Test - fun `should call advanceOldestLoadedDate with channel messages when loading older`() { - // Given - val messages = (1..10).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m20", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).advanceOldestLoadedDate(messages) - } - - @Test - fun `should NOT call advanceOldestLoadedDate from updateDataForChannel`() = runTest { - // Given — updateDataForChannel is the QueryChannels path, not the paginated path - val messages = listOf(randomMessage(id = "m1")) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - pendingMessages = emptyList(), - ) - // When - sut.updateDataForChannel(channel = channel, messageLimit = 30) - // Then — floor must only be set from queryChannel pagination, never from updateDataForChannel - verify(stateImpl, never()).advanceOldestLoadedDate(any()) - } - } - - // endregion - - // region PreservationCallSites - - @Nested - inner class PreservationCallSites { - - @Test - fun `onQueryChannelResult success with no filtering calls setMessagesPreservingLocalOnly`() { - // Given - val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessages(any()) - } - - @Test - fun `onQueryChannelResult success with aroundId filtering calls setMessagesPreservingLocalOnly`() { - // Given: insideSearch = false so we exercise the else branch - whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(false)) - val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "m1", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessages(any()) - } - - @Test - fun `onQueryChannelResult success inside search calls setMessagesPreservingLocalOnly`() { - // Given: insideSearch = true so we exercise the if branch (no caching) - whenever(stateImpl.insideSearch).thenReturn(MutableStateFlow(true)) - val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "m1", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessages(any()) - } - - @Test - fun `updateDataForChannel with shouldRefreshMessages true and isChannelsStateUpdate true calls setMessages not preservation`() = runTest { - // Given: existing state messages to ensure shouldRefresh branch is taken - // isChannelsStateUpdate=true is the DB-seed path (updateStateFromDatabase caller) - whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(randomMessage(id = "existing")))) - val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = listOf(incomingMsg), - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - // When: DB-seed path — isChannelsStateUpdate=true means OfflinePlugin already includes local-only in DB data - sut.updateDataForChannel( - channel = channel, - messageLimit = 30, - shouldRefreshMessages = true, - isChannelsStateUpdate = true, - ) - // Then: DB-seed path must NOT use preservation — setMessages full-replace is required - verify(stateImpl).setMessages(any()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - } - - @Test - fun `onQueryChannelResult filteringOlderMessages calls upsertMessagesPreservingLocalOnly not setMessages`() { - // Given — LESS_THAN triggers filteringOlderMessages - val messages = (1..10).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m20", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — pagination merges; trimNewestMessages still called - verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) - verify(stateImpl).trimNewestMessages() - } - - @Test - fun `onQueryChannelResult isFilteringNewerMessages not end reached calls upsertMessagesPreservingLocalOnly`() { - // Given — GREATER_THAN + limit=5, messages.size=5 → endReached=false - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 5) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — pagination merges; trimOldestMessages still called - verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) - verify(stateImpl).trimOldestMessages() - } - - @Test - fun `onQueryChannelResult isFilteringNewerMessages end reached calls upsertMessagesPreservingLocalOnly with page floor`() { - // Given — GREATER_THAN + limit=30, messages.size=5 → endReached=true - val messages = (1..5).map { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", - type = "messaging", - messages = messages, - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — end reached: merge with the page's floor (not null); window floor is preserved - verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) - // And exit-search state management still happens - verify(stateImpl).clearCachedLatestMessages() - verify(stateImpl).setInsideSearch(false) - } - - @Test - fun `updateDataForChannel with shouldRefreshMessages true and isChannelsStateUpdate false calls preservation`() = runTest { - // Given: SyncManager reconnect path — shouldRefreshMessages=true but isChannelsStateUpdate=false - whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(randomMessage(id = "existing")))) - val incomingMsg = randomMessage(id = "new", createdAt = Date(5000L), createdLocallyAt = null) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = listOf(incomingMsg), - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - // When: reconnect path — isChannelsStateUpdate=false (default) - sut.updateDataForChannel( - channel = channel, - messageLimit = 30, - shouldRefreshMessages = true, - isChannelsStateUpdate = false, - ) - // Then: SyncManager reconnect must use preservation to keep local-only messages - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessages(any()) - } - - @Test - fun `updateDataForChannel else upsert branch calls setMessagesPreservingLocalOnly`() = runTest { - // Given: contiguous incoming messages (no gap, no refresh, has existing messages) - val existingMsg = randomMessage(id = "existing", createdAt = Date(1000L), createdLocallyAt = null) - whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) - val incomingMsg = randomMessage(id = "new", createdAt = Date(500L), createdLocallyAt = null) - val channel = randomChannel( - id = "123", - type = "messaging", - messages = listOf(incomingMsg), - members = emptyList(), - watchers = emptyList(), - read = emptyList(), - memberCount = 0, - watcherCount = 0, - ) - // When: else branch (contiguous, not refresh, not insideSearch, no gap) - sut.updateDataForChannel(channel = channel, messageLimit = 30) - // Then: else branch uses preservation not plain upsert - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) - } - } - - // endregion - - // region PaginationPreservation - - @Nested - inner class PaginationPreservation { - - @Test - fun `filteringOlderMessages calls upsertMessagesPreservingLocalOnly not setMessages`() { - // Given - val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) - val channel = randomChannel( - id = "123", type = "messaging", messages = messages, - members = emptyList(), watchers = emptyList(), read = emptyList(), - memberCount = 0, watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "m0", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — pagination must merge, not replace - verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) - verify(stateImpl, never()).setMessages(any()) - } - - @Test - fun `filteringNewerMessages mid-page calls upsertMessagesPreservingLocalOnly not setMessages`() { - // Given: size == limit → not end-reached - val messages = List(30) { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", type = "messaging", messages = messages, - members = emptyList(), watchers = emptyList(), read = emptyList(), - memberCount = 0, watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m-1", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then — pagination must merge, not replace - verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) - verify(stateImpl, never()).setMessages(any()) - } - - @Test - fun `filteringNewerMessages end-reached calls upsertMessagesPreservingLocalOnly with page floor`() { - // Given: size < limit → end-reached - val messages = listOf(randomMessage(id = "m1"), randomMessage(id = "m2")) - val channel = randomChannel( - id = "123", type = "messaging", messages = messages, - members = emptyList(), watchers = emptyList(), read = emptyList(), - memberCount = 0, watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m0", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then: page's windowFloor passed (not null) — window floor stays at oldest loaded, not reset - verify(stateImpl).upsertMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) - verify(stateImpl, never()).setMessages(any()) - } - - @Test - fun `filteringNewerMessages mid-page still advances newest loaded date`() { - // Given: size == limit → not end-reached - val messages = List(30) { randomMessage(id = "m$it") } - val channel = randomChannel( - id = "123", type = "messaging", messages = messages, - members = emptyList(), watchers = emptyList(), read = emptyList(), - memberCount = 0, watcherCount = 0, - ) - val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "m-1", 30) - // When - sut.onQueryChannelResult(query, Result.Success(channel)) - // Then: ceiling must still advance so pending messages above the page are hidden - verify(stateImpl).advanceNewestLoadedDate(anyOrNull()) - } - } - - // endregion - - // region ReconnectPreservation - - @Nested - inner class ReconnectPreservation { - - @Test - fun `updateDataForChannel reconnect path calls setMessagesPreservingLocalOnly`() = runTest { - // Given: isChannelsStateUpdate=false (SyncManager reconnect), shouldRefreshMessages=true - val incomingMsg = randomMessage(id = "server1") - val channel = randomChannel( - id = "123", type = "messaging", messages = listOf(incomingMsg), - members = emptyList(), watchers = emptyList(), read = emptyList(), - memberCount = 0, watcherCount = 0, - ) - // When: reconnect path — isChannelsStateUpdate defaults to false - sut.updateDataForChannel( - channel = channel, - messageLimit = 30, - shouldRefreshMessages = true, - isChannelsStateUpdate = false, - ) - // Then - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).setMessages(any()) - } - - @Test - fun `updateDataForChannel DB-seed path calls setMessages not preservation`() = runTest { - // Given: isChannelsStateUpdate=true (updateStateFromDatabase DB-seed) - whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(randomMessage(id = "existing")))) - val incomingMsg = randomMessage(id = "new") - val channel = randomChannel( - id = "123", type = "messaging", messages = listOf(incomingMsg), - members = emptyList(), watchers = emptyList(), read = emptyList(), - memberCount = 0, watcherCount = 0, - ) - // When: DB-seed path — isChannelsStateUpdate=true - sut.updateDataForChannel( - channel = channel, - messageLimit = 30, - shouldRefreshMessages = true, - isChannelsStateUpdate = true, - ) - // Then: full-replace required, local-only messages already in DB data - verify(stateImpl).setMessages(any()) - verify(stateImpl, never()).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - } - - @Test - fun `updateDataForChannel upsert else-branch calls setMessagesPreservingLocalOnly`() = runTest { - // Given: currentMessages non-empty, incoming overlaps with existing (same ID → hasGap = false) - // Using same ID guarantees no gap (ID overlap short-circuits the date check in hasGap) - val sharedId = "shared-msg" - val existingMsg = randomMessage(id = sharedId) - whenever(stateImpl.messages).thenReturn(MutableStateFlow(listOf(existingMsg))) - val incomingMsg = randomMessage(id = sharedId) // same ID = overlap → no gap - val channel = randomChannel( - id = "123", type = "messaging", messages = listOf(incomingMsg), - members = emptyList(), watchers = emptyList(), read = emptyList(), - memberCount = 0, watcherCount = 0, - ) - // When: shouldRefreshMessages=false, currentMessages non-empty, no gap → falls to else branch - sut.updateDataForChannel( - channel = channel, - messageLimit = 30, - shouldRefreshMessages = false, - isChannelsStateUpdate = false, - ) - // Then - verify(stateImpl).setMessagesPreservingLocalOnly(any(), anyOrNull(), anyOrNull()) - verify(stateImpl, never()).upsertMessages(any()) - } } // endregion diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplNonChannelStatesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplNonChannelStatesTest.kt index 18a9f4f399c..900f38191b3 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplNonChannelStatesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplNonChannelStatesTest.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal -import io.getstream.chat.android.models.MessagesState import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -29,167 +28,6 @@ import org.junit.jupiter.api.Test @ExperimentalCoroutinesApi internal class ChannelStateImplNonChannelStatesTest : ChannelStateImplTestBase() { - // region Loading - - @Nested - inner class SetLoading { - - @Test - fun `loading should default to false`() = runTest { - assertFalse(channelState.loading.value) - } - - @Test - fun `setLoading should set loading to true`() = runTest { - // when - channelState.setLoading(true) - // then - assertTrue(channelState.loading.value) - } - - @Test - fun `setLoading should set loading to false`() = runTest { - // given - channelState.setLoading(true) - // when - channelState.setLoading(false) - // then - assertFalse(channelState.loading.value) - } - - @Test - fun `messagesState should be Loading when loading is true`() = runTest { - // when - channelState.setLoading(true) - // then - assertTrue(channelState.messagesState.value is MessagesState.Loading) - } - - @Test - fun `messagesState should be OfflineNoResults when loading is false and no messages`() = runTest { - // when - channelState.setLoading(false) - // then - assertTrue(channelState.messagesState.value is MessagesState.OfflineNoResults) - } - - @Test - fun `messagesState should be Result when loading is false and messages exist`() = runTest { - // given - channelState.setMessages(listOf(createMessage(1))) - // when - channelState.setLoading(false) - // then - assertTrue(channelState.messagesState.value is MessagesState.Result) - } - } - - // endregion - - // region LoadingOlderMessages - - @Nested - inner class SetLoadingOlderMessages { - - @Test - fun `loadingOlderMessages should default to false`() = runTest { - assertFalse(channelState.loadingOlderMessages.value) - } - - @Test - fun `setLoadingOlderMessages should set to true`() = runTest { - channelState.setLoadingOlderMessages(true) - assertTrue(channelState.loadingOlderMessages.value) - } - - @Test - fun `setLoadingOlderMessages should set to false`() = runTest { - channelState.setLoadingOlderMessages(true) - channelState.setLoadingOlderMessages(false) - assertFalse(channelState.loadingOlderMessages.value) - } - } - - // endregion - - // region LoadingNewerMessages - - @Nested - inner class SetLoadingNewerMessages { - - @Test - fun `loadingNewerMessages should default to false`() = runTest { - assertFalse(channelState.loadingNewerMessages.value) - } - - @Test - fun `setLoadingNewerMessages should set to true`() = runTest { - channelState.setLoadingNewerMessages(true) - assertTrue(channelState.loadingNewerMessages.value) - } - - @Test - fun `setLoadingNewerMessages should set to false`() = runTest { - channelState.setLoadingNewerMessages(true) - channelState.setLoadingNewerMessages(false) - assertFalse(channelState.loadingNewerMessages.value) - } - } - - // endregion - - // region EndOfOlderMessages - - @Nested - inner class SetEndOfOlderMessages { - - @Test - fun `endOfOlderMessages should default to false`() = runTest { - assertFalse(channelState.endOfOlderMessages.value) - } - - @Test - fun `setEndOfOlderMessages should set to true`() = runTest { - channelState.setEndOfOlderMessages(true) - assertTrue(channelState.endOfOlderMessages.value) - } - - @Test - fun `setEndOfOlderMessages should set to false`() = runTest { - channelState.setEndOfOlderMessages(true) - channelState.setEndOfOlderMessages(false) - assertFalse(channelState.endOfOlderMessages.value) - } - } - - // endregion - - // region EndOfNewerMessages - - @Nested - inner class SetEndOfNewerMessages { - - @Test - fun `endOfNewerMessages should default to true`() = runTest { - assertTrue(channelState.endOfNewerMessages.value) - } - - @Test - fun `setEndOfNewerMessages should set to false`() = runTest { - channelState.setEndOfNewerMessages(false) - assertFalse(channelState.endOfNewerMessages.value) - } - - @Test - fun `setEndOfNewerMessages should set to true`() = runTest { - channelState.setEndOfNewerMessages(false) - channelState.setEndOfNewerMessages(true) - assertTrue(channelState.endOfNewerMessages.value) - } - } - - // endregion - // region RecoveryNeeded @Nested @@ -388,7 +226,7 @@ internal class ChannelStateImplNonChannelStatesTest : ChannelStateImplTestBase() fun `trimOldestMessages should set endOfOlderMessages to false`() = runTest { // given val stateWithLimit = createChannelStateWithLimit(50) - stateWithLimit.setEndOfOlderMessages(true) + stateWithLimit.paginationManager.setEndOfOlderMessages(true) val messages = createMessages(81) stateWithLimit.setMessages(messages) // when @@ -469,7 +307,7 @@ internal class ChannelStateImplNonChannelStatesTest : ChannelStateImplTestBase() fun `trimNewestMessages should set endOfNewerMessages to false`() = runTest { // given val stateWithLimit = createChannelStateWithLimit(50) - stateWithLimit.setEndOfNewerMessages(true) + stateWithLimit.paginationManager.setEndOfNewerMessages(true) val messages = createMessages(81) stateWithLimit.setMessages(messages) // when @@ -518,11 +356,8 @@ internal class ChannelStateImplNonChannelStatesTest : ChannelStateImplTestBase() fun `destroy should reset all state to defaults`() = runTest { // given - populate various state channelState.setMessages(createMessages(5)) - channelState.setLoading(true) - channelState.setLoadingOlderMessages(true) - channelState.setLoadingNewerMessages(true) - channelState.setEndOfOlderMessages(true) - channelState.setEndOfNewerMessages(false) + channelState.paginationManager.setEndOfOlderMessages(true) + channelState.paginationManager.setEndOfNewerMessages(false) channelState.setRecoveryNeeded(true) channelState.setInsideSearch(true) channelState.setHidden(true) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt index 2f96fdace04..7836a5341ca 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt @@ -108,22 +108,6 @@ internal class ChannelStateImplPendingMessagesTest : ChannelStateImplTestBase() assertEquals(listOf(regular.id, pending.id), ids) } - @Test - fun `multiple pending messages are merged in sorted order`() { - // Given - enablePendingMessages() - val r1 = createMessage(1, timestamp = 1000L) - val r2 = createMessage(4, timestamp = 4000L) - val p1 = createMessage(2, timestamp = 2000L) - val p2 = createMessage(3, timestamp = 3000L) - channelState.setMessages(listOf(r1, r2)) - // When - channelState.setPendingMessages(listOf(p2, p1)) // intentionally reversed - // Then - val ids = channelState.messages.value.map { it.id } - assertEquals(listOf(r1.id, p1.id, p2.id, r2.id), ids) - } - @Test fun `messages flow contains only regular messages when pending list is empty`() { // Given @@ -139,74 +123,65 @@ internal class ChannelStateImplPendingMessagesTest : ChannelStateImplTestBase() // endregion - // region date range filtering via ChannelStateImpl delegation + // region date range filtering via paginationManager state @Nested inner class DateRangeFiltering { @Test fun `pending message below oldest loaded date is not shown`() { - // Given enablePendingMessages() val pending = createMessage(1, timestamp = 500L) val floor = randomMessage(id = "floor", createdAt = Date(1000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) - // When — floor = 1000, pending at 500 is below it - channelState.advanceOldestLoadedDate(listOf(floor)) - // Then + channelState.paginationManager.setOldestMessage(floor) assertFalse(channelState.messages.value.any { it.id == pending.id }) } @Test fun `pending message above newest loaded date ceiling is not shown`() { - // Given enablePendingMessages() val pending = createMessage(1, timestamp = 3000L) + val ceiling = randomMessage(id = "floor", createdAt = Date(2000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) - // When — ceiling = 2000, pending at 3000 is above it - channelState.setNewestLoadedDate(Date(2000L)) - // Then + channelState.paginationManager.setNewestMessage(ceiling) assertFalse(channelState.messages.value.any { it.id == pending.id }) } @Test fun `pending message within both floor and ceiling is shown`() { - // Given enablePendingMessages() val pending = createMessage(1, timestamp = 2000L) - val floorMsg = randomMessage(id = "f", createdAt = Date(1000L), createdLocallyAt = null) + val floor = randomMessage(id = "f", createdAt = Date(1000L), createdLocallyAt = null) + val ceiling = randomMessage(id = "c", createdAt = Date(3000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) - channelState.advanceOldestLoadedDate(listOf(floorMsg)) // floor = 1000 - channelState.setNewestLoadedDate(Date(3000L)) // ceiling = 3000 - // Then + channelState.paginationManager.setOldestMessage(floor) + channelState.paginationManager.setNewestMessage(ceiling) assertTrue(channelState.messages.value.any { it.id == pending.id }) } @Test - fun `setNewestLoadedDate null removes ceiling and reveals previously hidden pending messages`() { - // Given + fun `removing ceiling reveals previously hidden pending messages`() { enablePendingMessages() val pending = createMessage(1, timestamp = 5000L) + val ceiling = randomMessage(id = "c", createdAt = Date(1000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) - channelState.setNewestLoadedDate(Date(1000L)) + channelState.paginationManager.setNewestMessage(ceiling) assertFalse(channelState.messages.value.any { it.id == pending.id }) - // When - channelState.setNewestLoadedDate(null) - // Then + channelState.paginationManager.setNewestMessage(null) assertTrue(channelState.messages.value.any { it.id == pending.id }) } @Test - fun `advanceNewestLoadedDate advances ceiling to reveal newer pending messages`() { - // Given + fun `advancing ceiling reveals newer pending messages`() { enablePendingMessages() val pending = createMessage(1, timestamp = 3000L) channelState.setPendingMessages(listOf(pending)) - channelState.advanceNewestLoadedDate(Date(2000L)) // hidden + val ceiling1 = randomMessage(id = "c", createdAt = Date(2000L), createdLocallyAt = null) + channelState.paginationManager.setNewestMessage(ceiling1) assertFalse(channelState.messages.value.any { it.id == pending.id }) - // When — advance to 4000 - channelState.advanceNewestLoadedDate(Date(4000L)) - // Then + val ceiling2 = randomMessage(id = "c", createdAt = Date(4000L), createdLocallyAt = null) + channelState.paginationManager.setNewestMessage(ceiling2) assertTrue(channelState.messages.value.any { it.id == pending.id }) } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt deleted file mode 100644 index 36e155bb866..00000000000 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPreservationTest.kt +++ /dev/null @@ -1,459 +0,0 @@ -/* - * 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.MessageType -import io.getstream.chat.android.models.SyncStatus -import io.getstream.chat.android.randomMessage -import kotlinx.coroutines.ExperimentalCoroutinesApi -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import java.util.Date -import java.util.concurrent.TimeUnit - -/** - * Tests for [ChannelStateImpl.setMessagesPreservingLocalOnly]. - * - * Requirements covered: PRES-01, PRES-04, PRES-05 (state layer) - * - * Setup mirrors [ChannelStateImplTestBase] — extends the base class to reuse - * the channelState fixture and createMessage/createMessages helpers. - */ -@ExperimentalCoroutinesApi -internal class ChannelStateImplPreservationTest : ChannelStateImplTestBase() { - - // ----------------------------------------------------------------------- - // Case 1: FAILED_PERMANENTLY message survives non-overlapping incoming - // ----------------------------------------------------------------------- - @Test - fun `failed message survives setMessagesPreservingLocalOnly with non-overlapping incoming`() { - val localMsg = randomMessage( - id = "local-failed", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(localMsg)) - - val serverMsg = randomMessage( - id = "server-1", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), - createdLocallyAt = null, - parentId = null, - ) - - channelState.setMessagesPreservingLocalOnly( - incoming = listOf(serverMsg), - localOnlyFromDb = emptyList(), - windowFloor = null, - ) - - val ids = channelState.messages.value.map { it.id } - assertTrue(ids.contains("local-failed"), "FAILED_PERMANENTLY message must survive") - assertTrue(ids.contains("server-1"), "Server message must be present") - } - - // ----------------------------------------------------------------------- - // Case 2: ephemeral (type==ephemeral, COMPLETED) survives - // ----------------------------------------------------------------------- - @Test - fun `ephemeral message survives setMessagesPreservingLocalOnly`() { - val ephemeral = randomMessage( - id = "local-ephemeral", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.EPHEMERAL, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(ephemeral)) - - val serverMsg = randomMessage( - id = "server-1", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), - createdLocallyAt = null, - parentId = null, - ) - - channelState.setMessagesPreservingLocalOnly( - incoming = listOf(serverMsg), - localOnlyFromDb = emptyList(), - windowFloor = null, - ) - - val ids = channelState.messages.value.map { it.id } - assertTrue(ids.contains("local-ephemeral"), "Ephemeral message must survive") - } - - // ----------------------------------------------------------------------- - // Case 3: AWAITING_ATTACHMENTS survives - // ----------------------------------------------------------------------- - @Test - fun `AWAITING_ATTACHMENTS message survives setMessagesPreservingLocalOnly`() { - val awaitingMsg = randomMessage( - id = "local-awaiting", - syncStatus = SyncStatus.AWAITING_ATTACHMENTS, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(awaitingMsg)) - - val serverMsg = randomMessage( - id = "server-1", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), - createdLocallyAt = null, - parentId = null, - ) - - channelState.setMessagesPreservingLocalOnly( - incoming = listOf(serverMsg), - localOnlyFromDb = emptyList(), - windowFloor = null, - ) - - val ids = channelState.messages.value.map { it.id } - assertTrue(ids.contains("local-awaiting"), "AWAITING_ATTACHMENTS message must survive") - } - - // ----------------------------------------------------------------------- - // Case 4: SYNC_NEEDED with server-assigned ID (pending edit) survives - // ----------------------------------------------------------------------- - @Test - fun `pending edit SYNC_NEEDED on existing server ID survives setMessagesPreservingLocalOnly`() { - // A message that was sent (has a server ID) but has a pending edit - val pendingEdit = randomMessage( - id = "server-existing-id", - syncStatus = SyncStatus.SYNC_NEEDED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(pendingEdit)) - - // incoming does NOT include the same ID - val serverMsg = randomMessage( - id = "server-other", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), - createdLocallyAt = null, - parentId = null, - ) - - channelState.setMessagesPreservingLocalOnly( - incoming = listOf(serverMsg), - localOnlyFromDb = emptyList(), - windowFloor = null, - ) - - val ids = channelState.messages.value.map { it.id } - assertTrue(ids.contains("server-existing-id"), "SYNC_NEEDED pending edit must survive") - } - - // ----------------------------------------------------------------------- - // Case 5: server COMPLETED version wins on ID collision - // ----------------------------------------------------------------------- - @Test - fun `server COMPLETED version wins when same ID in both incoming and local-only`() { - val localVersion = randomMessage( - id = "msg-collision", - syncStatus = SyncStatus.SYNC_NEEDED, - type = MessageType.REGULAR, - text = "local text", - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(localVersion)) - - val serverVersion = randomMessage( - id = "msg-collision", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.REGULAR, - text = "server text", - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - - channelState.setMessagesPreservingLocalOnly( - incoming = listOf(serverVersion), - localOnlyFromDb = emptyList(), - windowFloor = null, - ) - - val messages = channelState.messages.value - val result = messages.find { it.id == "msg-collision" } - assertFalse(result == null, "Message with collision ID must be in result") - assertEquals(SyncStatus.COMPLETED, result!!.syncStatus, "Server COMPLETED version must win") - assertEquals("server text", result.text, "Server text must win") - // Only one message with that ID - assertEquals(1, messages.count { it.id == "msg-collision" }, "Must be exactly one entry for collision ID") - } - - // ----------------------------------------------------------------------- - // Case 6: window floor filtering — below excluded, above included - // ----------------------------------------------------------------------- - @Test - fun `below-floor local-only excluded above-floor included`() { - val now = System.currentTimeMillis() - val oneDayMs = TimeUnit.DAYS.toMillis(1) - val oneHourMs = TimeUnit.HOURS.toMillis(1) - - // Below floor: 2 days ago; floor is 1 day ago => excluded - val belowFloor = randomMessage( - id = "below-floor", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(now - 2 * oneDayMs), - createdLocallyAt = null, - parentId = null, - ) - // Above floor: 1 hour ago; floor is 1 day ago => included - val aboveFloor = randomMessage( - id = "above-floor", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(now - oneHourMs), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(belowFloor, aboveFloor)) - - val windowFloor = Date(now - oneDayMs) - - channelState.setMessagesPreservingLocalOnly( - incoming = emptyList(), - localOnlyFromDb = emptyList(), - windowFloor = windowFloor, - ) - - val ids = channelState.messages.value.map { it.id } - assertFalse(ids.contains("below-floor"), "Below-floor local-only must be excluded") - assertTrue(ids.contains("above-floor"), "Above-floor local-only must be included") - } - - // ----------------------------------------------------------------------- - // Case 7: message at exactly windowFloor is included (>= not >) - // ----------------------------------------------------------------------- - @Test - fun `floor boundary message at exactly floor date is included`() { - val now = System.currentTimeMillis() - val oneDayMs = TimeUnit.DAYS.toMillis(1) - val floorTime = now - oneDayMs - - val atFloor = randomMessage( - id = "at-floor", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(floorTime), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(atFloor)) - - val windowFloor = Date(floorTime) - - channelState.setMessagesPreservingLocalOnly( - incoming = emptyList(), - localOnlyFromDb = emptyList(), - windowFloor = windowFloor, - ) - - val ids = channelState.messages.value.map { it.id } - assertTrue(ids.contains("at-floor"), "Message at exactly windowFloor must be included (>= not >)") - } - - // ----------------------------------------------------------------------- - // Case 8: windowFloor = null — all local-only included - // ----------------------------------------------------------------------- - @Test - fun `empty incoming page with null floor includes all local-only`() { - val old = randomMessage( - id = "local-old", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30)), - createdLocallyAt = null, - parentId = null, - ) - val recent = randomMessage( - id = "local-recent", - syncStatus = SyncStatus.SYNC_NEEDED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(old, recent)) - - channelState.setMessagesPreservingLocalOnly( - incoming = emptyList(), - localOnlyFromDb = emptyList(), - windowFloor = null, - ) - - val ids = channelState.messages.value.map { it.id } - assertTrue(ids.contains("local-old"), "Old local-only must be included when floor is null") - assertTrue(ids.contains("local-recent"), "Recent local-only must be included when floor is null") - } - - // ----------------------------------------------------------------------- - // Case 9: localOnlyFromDb = emptyList(), in-memory local-only still preserved - // ----------------------------------------------------------------------- - @Test - fun `localOnlyFromDb empty no-DB path local-only from state messages value preserved`() { - val localMsg = randomMessage( - id = "in-memory-local", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(localMsg)) - - val serverMsg = randomMessage( - id = "server-1", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), - createdLocallyAt = null, - parentId = null, - ) - - channelState.setMessagesPreservingLocalOnly( - incoming = listOf(serverMsg), - localOnlyFromDb = emptyList(), // no DB — in-memory fallback - windowFloor = null, - ) - - val ids = channelState.messages.value.map { it.id } - assertTrue(ids.contains("in-memory-local"), "In-memory local-only must be preserved even when localOnlyFromDb is empty") - } - - // ----------------------------------------------------------------------- - // Case 10: localOnlyFromDb non-empty — union of state and DB, deduped by ID - // ----------------------------------------------------------------------- - @Test - fun `localOnlyFromDb non-empty union of state and DB deduped`() { - // In-memory state has one local-only - val inStateLocal = randomMessage( - id = "state-local", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(inStateLocal)) - - // DB has a different local-only (not in state) - val dbOnlyLocal = randomMessage( - id = "db-local", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis() - TimeUnit.MINUTES.toMillis(5)), - createdLocallyAt = null, - parentId = null, - ) - - channelState.setMessagesPreservingLocalOnly( - incoming = emptyList(), - localOnlyFromDb = listOf(dbOnlyLocal), - windowFloor = null, - ) - - val ids = channelState.messages.value.map { it.id } - assertTrue(ids.contains("state-local"), "In-state local-only must be in union result") - assertTrue(ids.contains("db-local"), "DB local-only must be in union result") - // Dedup: no duplicates - assertEquals(ids.size, ids.toSet().size, "No duplicate IDs in result") - } - - // ----------------------------------------------------------------------- - // Case 11: COMPLETED messages NOT included in survivingLocalOnly - // ----------------------------------------------------------------------- - @Test - fun `COMPLETED messages not re-inserted from state isLocalOnly returns false`() { - val completedMsg = randomMessage( - id = "completed-msg", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(completedMsg)) - - // Incoming is empty — no server messages - channelState.setMessagesPreservingLocalOnly( - incoming = emptyList(), - localOnlyFromDb = emptyList(), - windowFloor = null, - ) - - val ids = channelState.messages.value.map { it.id } - assertFalse(ids.contains("completed-msg"), "COMPLETED message must NOT be preserved (isLocalOnly() = false)") - } - - // ----------------------------------------------------------------------- - // Case 12: setMessages retains full-replace semantics — NOT preservation - // ----------------------------------------------------------------------- - @Test - fun `setMessages DB seed does NOT preserve local-only full replace semantics intact`() { - // Seed state with a local-only message - val localMsg = randomMessage( - id = "local-pending", - syncStatus = SyncStatus.FAILED_PERMANENTLY, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis()), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(localMsg)) - - // setMessages called with server messages (DB seed path — full replace) - val serverMsg = randomMessage( - id = "server-db-seed", - syncStatus = SyncStatus.COMPLETED, - type = MessageType.REGULAR, - createdAt = Date(System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)), - createdLocallyAt = null, - parentId = null, - ) - channelState.setMessages(listOf(serverMsg)) - - val ids = channelState.messages.value.map { it.id } - // Full replace: local-only is gone - assertFalse(ids.contains("local-pending"), "setMessages must NOT preserve local-only (full-replace semantics)") - assertTrue(ids.contains("server-db-seed"), "setMessages must contain the new messages") - } -} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt deleted file mode 100644 index 5243d452c08..00000000000 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessageIsLocalOnlyTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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.MessageType -import io.getstream.chat.android.models.SyncStatus -import io.getstream.chat.android.randomMessage -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -/** - * Unit tests for the [Message.isLocalOnly] predicate. - * - * Requirements covered: PRES-05 - */ -internal class MessageIsLocalOnlyTest { - - @Test - fun `isLocalOnly returns true for SyncStatus SYNC_NEEDED`() { - val message = randomMessage(syncStatus = SyncStatus.SYNC_NEEDED, type = MessageType.REGULAR) - assertTrue(message.isLocalOnly()) - } - - @Test - fun `isLocalOnly returns true for SyncStatus IN_PROGRESS`() { - val message = randomMessage(syncStatus = SyncStatus.IN_PROGRESS, type = MessageType.REGULAR) - assertTrue(message.isLocalOnly()) - } - - @Test - fun `isLocalOnly returns true for SyncStatus AWAITING_ATTACHMENTS`() { - val message = randomMessage(syncStatus = SyncStatus.AWAITING_ATTACHMENTS, type = MessageType.REGULAR) - assertTrue(message.isLocalOnly()) - } - - @Test - fun `isLocalOnly returns true for SyncStatus FAILED_PERMANENTLY`() { - val message = randomMessage(syncStatus = SyncStatus.FAILED_PERMANENTLY, type = MessageType.REGULAR) - assertTrue(message.isLocalOnly()) - } - - @Test - fun `isLocalOnly returns true for type ephemeral with COMPLETED syncStatus`() { - val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.EPHEMERAL) - assertTrue(message.isLocalOnly()) - } - - @Test - fun `isLocalOnly returns true for type error with COMPLETED syncStatus`() { - val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.ERROR) - assertTrue(message.isLocalOnly()) - } - - @Test - fun `isLocalOnly returns false for SyncStatus COMPLETED with type regular`() { - val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.REGULAR) - assertFalse(message.isLocalOnly()) - } - - @Test - fun `isLocalOnly returns false for system message with COMPLETED`() { - val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.SYSTEM) - assertFalse(message.isLocalOnly()) - } -} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationManagerImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationManagerImplTest.kt new file mode 100644 index 00000000000..87d3054f6f7 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationManagerImplTest.kt @@ -0,0 +1,676 @@ +/* + * 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.client.api.models.Pagination +import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomMessage +import io.getstream.result.Error +import io.getstream.result.Result +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test + +internal class MessagesPaginationManagerImplTest { + + private lateinit var sut: MessagesPaginationManagerImpl + private val failure = Result.Failure(Error.ThrowableError("test", RuntimeException())) + + @BeforeEach + fun setUp() { + sut = MessagesPaginationManagerImpl() + } + + // region Initial state + + @Test + fun `initial state should have hasLoadedAllNextMessages = true`() { + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `initial state should have hasLoadedAllPreviousMessages = false`() { + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `initial state should have no loading flags set`() { + val state = sut.state.value + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `initial state should have null oldest and newest messages`() { + assertNull(sut.state.value.oldestMessage) + assertNull(sut.state.value.newestMessage) + } + + // endregion + + // region begin() + + @Nested + inner class Begin { + + @Test + fun `begin with no pagination should reset to initial state`() { + // given - dirty state + sut.setEndOfOlderMessages(true) + sut.setEndOfNewerMessages(false) + // when + sut.begin(QueryChannelRequest().withMessages(30)) + // then + val state = sut.state.value + assertTrue(state.hasLoadedAllNextMessages) + assertFalse(state.hasLoadedAllPreviousMessages) + assertFalse(state.isLoadingMessages) + } + + @Test + fun `begin with older pagination should set isLoadingPreviousMessages`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30)) + // then + assertTrue(sut.state.value.isLoadingPreviousMessages) + } + + @Test + fun `begin with older pagination should not touch other loading flags`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30)) + // then + val state = sut.state.value + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `begin with newer pagination should set isLoadingNextMessages`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30)) + // then + assertTrue(sut.state.value.isLoadingNextMessages) + } + + @Test + fun `begin with newer pagination should not touch other loading flags`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30)) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `begin with around pagination should set isLoadingMiddleMessages`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.AROUND_ID, "msgId", 30)) + // then + assertTrue(sut.state.value.isLoadingMiddleMessages) + } + + @Test + fun `begin with around pagination should set hasLoadedAllNextMessages to false`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.AROUND_ID, "msgId", 30)) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `begin with around pagination should not touch other loading flags`() { + // when + sut.begin(QueryChannelRequest().withMessages(Pagination.AROUND_ID, "msgId", 30)) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + } + } + + // endregion + + // region end() - failure + + @Nested + inner class EndFailure { + + @Test + fun `end with failure should clear all loading flags`() { + // given + sut.begin(QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30)) + // when + sut.end( + query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30), + result = failure, + ) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `end with failure should preserve hasLoadedAllPreviousMessages`() { + // given + sut.setEndOfOlderMessages(true) + // when + sut.end( + query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30), + result = failure, + ) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `end with failure should preserve hasLoadedAllNextMessages`() { + // given + sut.setEndOfNewerMessages(false) + // when + sut.end( + query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30), + result = failure, + ) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + } + + // endregion + + // region end() - success - older + + @Nested + inner class EndSuccessOlder { + + private val query = QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30) + + @Test + fun `full page should set hasLoadedAllPreviousMessages to false`() { + // given - full page (30 messages == limit) + val messages = (1..30).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `partial page should set hasLoadedAllPreviousMessages to true`() { + // given - partial page (fewer messages than limit) + val messages = (1..10).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `empty page should set hasLoadedAllPreviousMessages to true`() { + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should set oldestMessage to first message in response`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertEquals("m1", sut.state.value.oldestMessage?.id) + } + + @Test + fun `should clear all loading flags on success`() { + // given + sut.begin(query) + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `should not change hasLoadedAllNextMessages`() { + // given - currently at the latest page + assertTrue(sut.state.value.hasLoadedAllNextMessages) + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then - not changed + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + } + + // endregion + + // region end() - success - newer + + @Nested + inner class EndSuccessNewer { + + private val query = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30) + + @Test + fun `full page should set hasLoadedAllNextMessages to false`() { + // given - full page + val messages = (1..30).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `full page should set newestMessage to last message in response`() { + // given + val messages = (1..30).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertEquals("m30", sut.state.value.newestMessage?.id) + } + + @Test + fun `partial page should set hasLoadedAllNextMessages to true`() { + // given - partial page + val messages = (1..10).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `partial page should clear newestMessage`() { + // given - partial page means we reached the end, no ceiling needed + val messages = (1..10).map { randomMessage(id = "m$it") } + val channel = randomChannel(messages = messages) + // when + sut.end(query, Result.Success(channel)) + // then + assertNull(sut.state.value.newestMessage) + } + + @Test + fun `should clear all loading flags on success`() { + // given + sut.begin(query) + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + } + + // endregion + + // region end() - success - around + + @Nested + inner class EndSuccessAround { + + private val query = QueryChannelRequest().withMessages(Pagination.AROUND_ID, "msgId", 30) + + @Test + fun `should always set hasLoadedAllNextMessages to false`() { + // given - even a partial page + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `should always set hasLoadedAllPreviousMessages to false`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should set oldestMessage to first message in response`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertEquals("m1", sut.state.value.oldestMessage?.id) + } + + @Test + fun `should set newestMessage to last message in response`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertEquals("m5", sut.state.value.newestMessage?.id) + } + + @Test + fun `should clear all loading flags on success`() { + // given + sut.begin(query) + // when + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + + @Test + fun `should set both end flags to false even when previously true`() { + // given - both flags were set + sut.setEndOfOlderMessages(true) + sut.setEndOfNewerMessages(true) + // when + val messages = (1..10).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + } + + // endregion + + // region end() - success - no pagination + + @Nested + inner class EndSuccessNoPagination { + + private val query = QueryChannelRequest().withMessages(30) + + @Test + fun `should set hasLoadedAllNextMessages to true`() { + // when + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `full page should set hasLoadedAllPreviousMessages to false`() { + // given - full page (30 messages == limit) + val messages = (1..30).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `partial page should set hasLoadedAllPreviousMessages to true`() { + // given - fewer than limit + val messages = (1..10).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should clear newestMessage ceiling`() { + // given - simulate a mid-page state with a ceiling + val olderQuery = QueryChannelRequest().withMessages(Pagination.GREATER_THAN, "msgId", 30) + sut.end(olderQuery, Result.Success(randomChannel(messages = (1..30).map { randomMessage(id = "m$it") }))) + // when - initial load resets state + sut.end(query, Result.Success(randomChannel(messages = emptyList()))) + // then + assertNull(sut.state.value.newestMessage) + } + + @Test + fun `should set oldestMessage to first message in response`() { + // given + val messages = (1..5).map { randomMessage(id = "m$it") } + sut.end(query, Result.Success(randomChannel(messages = messages))) + // then + assertEquals("m1", sut.state.value.oldestMessage?.id) + } + } + + // endregion + + // region setOldestMessage + + @Nested + inner class SetOldestMessage { + + @Test + fun `setOldestMessage should update oldestMessage`() { + // given + val message = randomMessage(id = "old") + // when + sut.setOldestMessage(message) + // then + assertEquals("old", sut.state.value.oldestMessage?.id) + } + + @Test + fun `setOldestMessage with null should clear oldestMessage`() { + // given + sut.setOldestMessage(randomMessage(id = "old")) + // when + sut.setOldestMessage(null) + // then + assertNull(sut.state.value.oldestMessage) + } + } + + // endregion + + // region setNewestMessage + + @Nested + inner class SetNewestMessage { + + @Test + fun `setNewestMessage should update newestMessage`() { + // given + val message = randomMessage(id = "new") + // when + sut.setNewestMessage(message) + // then + assertEquals("new", sut.state.value.newestMessage?.id) + } + + @Test + fun `setNewestMessage with null should clear newestMessage`() { + // given + sut.setNewestMessage(randomMessage(id = "new")) + // when + sut.setNewestMessage(null) + // then + assertNull(sut.state.value.newestMessage) + } + } + + // endregion + + // region setEndOfOlderMessages + + @Nested + inner class SetEndOfOlderMessages { + + @Test + fun `should set hasLoadedAllPreviousMessages to true`() { + // when + sut.setEndOfOlderMessages(true) + // then + assertTrue(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should set hasLoadedAllPreviousMessages to false`() { + // given + sut.setEndOfOlderMessages(true) + // when + sut.setEndOfOlderMessages(false) + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `should not affect hasLoadedAllNextMessages`() { + // given + val original = sut.state.value.hasLoadedAllNextMessages + // when + sut.setEndOfOlderMessages(true) + // then + assertEquals(original, sut.state.value.hasLoadedAllNextMessages) + } + } + + // endregion + + // region setEndOfNewerMessages + + @Nested + inner class SetEndOfNewerMessages { + + @Test + fun `should set hasLoadedAllNextMessages to false`() { + // when + sut.setEndOfNewerMessages(false) + // then + assertFalse(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `should set hasLoadedAllNextMessages to true`() { + // given + sut.setEndOfNewerMessages(false) + // when + sut.setEndOfNewerMessages(true) + // then + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `setting to true should clear newestMessage ceiling`() { + // given - a ceiling was set (mid-page state) + sut.setNewestMessage(randomMessage(id = "ceiling")) + // when + sut.setEndOfNewerMessages(true) + // then + assertNull(sut.state.value.newestMessage) + } + + @Test + fun `setting to false should preserve existing newestMessage`() { + // given + val ceiling = randomMessage(id = "ceiling") + sut.setNewestMessage(ceiling) + // when + sut.setEndOfNewerMessages(false) + // then + assertEquals("ceiling", sut.state.value.newestMessage?.id) + } + + @Test + fun `should not affect hasLoadedAllPreviousMessages`() { + // given + val original = sut.state.value.hasLoadedAllPreviousMessages + // when + sut.setEndOfNewerMessages(false) + // then + assertEquals(original, sut.state.value.hasLoadedAllPreviousMessages) + } + } + + // endregion + + // region reset + + @Nested + inner class Reset { + + @Test + fun `reset should restore hasLoadedAllNextMessages to true`() { + // given + sut.setEndOfNewerMessages(false) + // when + sut.reset() + // then + assertTrue(sut.state.value.hasLoadedAllNextMessages) + } + + @Test + fun `reset should restore hasLoadedAllPreviousMessages to false`() { + // given + sut.setEndOfOlderMessages(true) + // when + sut.reset() + // then + assertFalse(sut.state.value.hasLoadedAllPreviousMessages) + } + + @Test + fun `reset should clear oldest and newest messages`() { + // given + sut.setOldestMessage(randomMessage()) + sut.setNewestMessage(randomMessage()) + // when + sut.reset() + // then + assertNull(sut.state.value.oldestMessage) + assertNull(sut.state.value.newestMessage) + } + + @Test + fun `reset should clear all loading flags`() { + // given - simulate loading state + sut.begin(QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30)) + // when + sut.reset() + // then + val state = sut.state.value + assertFalse(state.isLoadingPreviousMessages) + assertFalse(state.isLoadingNextMessages) + assertFalse(state.isLoadingMiddleMessages) + } + } + + // endregion +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManagerTest.kt deleted file mode 100644 index f6e2cf63f8f..00000000000 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/PendingMessagesManagerTest.kt +++ /dev/null @@ -1,402 +0,0 @@ -/* - * 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.randomMessage -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Nested -import org.junit.jupiter.api.Test -import java.util.Date - -internal class PendingMessagesManagerTest { - - private lateinit var sut: PendingMessagesManager - - @BeforeEach - fun setUp() { - sut = PendingMessagesManager() - } - - // region initial state - - @Test - fun `pendingMessagesInRange is empty when disabled (initial state)`() { - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - // endregion - - // region setEnabled - - @Nested - inner class SetEnabled { - - @Test - fun `enabling makes pending messages visible`() { - // Given - val message = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(message)) - // When - sut.setEnabled(true) - // Then - assertEquals(listOf(message), sut.pendingMessagesInRange.value) - } - - @Test - fun `disabling returns empty list`() { - // Given - val message = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setEnabled(true) - sut.setPendingMessages(listOf(message)) - // When - sut.setEnabled(false) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - @Test - fun `disabling clears buffered messages so re-enabling starts empty`() { - // Given - val message = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setEnabled(true) - sut.setPendingMessages(listOf(message)) - // When - sut.setEnabled(false) - sut.setEnabled(true) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - } - - // endregion - - // region setPendingMessages - - @Nested - inner class SetPendingMessages { - - @Test - fun `messages are sorted by createdAt ascending`() { - // Given - sut.setEnabled(true) - val newer = randomMessage(id = "m1", createdAt = Date(2000L), createdLocallyAt = null) - val older = randomMessage(id = "m2", createdAt = Date(1000L), createdLocallyAt = null) - // When - sut.setPendingMessages(listOf(newer, older)) - // Then - assertEquals(listOf(older, newer), sut.pendingMessagesInRange.value) - } - - @Test - fun `replaces previously set messages`() { - // Given - sut.setEnabled(true) - val first = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(first)) - val second = randomMessage(id = "m2", createdAt = Date(2000L), createdLocallyAt = null) - // When - sut.setPendingMessages(listOf(second)) - // Then - assertEquals(listOf(second), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region removePendingMessage - - @Nested - inner class RemovePendingMessage { - - @Test - fun `removes existing message by id`() { - // Given - sut.setEnabled(true) - val m1 = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - val m2 = randomMessage(id = "m2", createdAt = Date(2000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(m1, m2)) - // When - sut.removePendingMessage("m1") - // Then - assertEquals(listOf(m2), sut.pendingMessagesInRange.value) - } - - @Test - fun `no-op when id is not found — list content is unchanged`() { - // Given - val m1 = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setEnabled(true) - sut.setPendingMessages(listOf(m1)) - // When - sut.removePendingMessage("does-not-exist") - // Then — message is still present, nothing was removed - assertEquals(listOf(m1), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region advanceOldestLoadedDate - - @Nested - inner class AdvanceOldestLoadedDate { - - @Test - fun `initializes floor on first call and shows messages at or after it`() { - // Given - sut.setEnabled(true) - val floor = Date(1000L) - val atFloor = randomMessage(id = "m1", createdAt = floor, createdLocallyAt = null) - val belowFloor = randomMessage(id = "m2", createdAt = Date(500L), createdLocallyAt = null) - sut.setPendingMessages(listOf(atFloor, belowFloor)) - // When — first call with a message whose createdAt = floor - sut.advanceOldestLoadedDate(listOf(atFloor)) - // Then - assertEquals(listOf(atFloor), sut.pendingMessagesInRange.value) - } - - @Test - fun `advances floor backward when new date is older`() { - // Given - sut.setEnabled(true) - val initial = randomMessage(id = "anchor", createdAt = Date(1000L), createdLocallyAt = null) - sut.advanceOldestLoadedDate(listOf(initial)) // floor = 1000 - val older = randomMessage(id = "m2", createdAt = Date(500L), createdLocallyAt = null) - sut.setPendingMessages(listOf(initial, older)) - // When — provide a message older than the current floor - sut.advanceOldestLoadedDate(listOf(older)) - // Then — older message is now in range - assertEquals(listOf(older, initial), sut.pendingMessagesInRange.value) - } - - @Test - fun `does NOT advance floor when new date is newer than current floor`() { - // Given - sut.setEnabled(true) - val floorMsg = randomMessage(id = "m1", createdAt = Date(500L), createdLocallyAt = null) - val outside = randomMessage(id = "m2", createdAt = Date(200L), createdLocallyAt = null) - sut.setPendingMessages(listOf(floorMsg, outside)) - sut.advanceOldestLoadedDate(listOf(floorMsg)) // floor = 500 - // When — try to advance with a newer date (1000 > 500) - val newer = randomMessage(id = "anchor2", createdAt = Date(1000L), createdLocallyAt = null) - sut.advanceOldestLoadedDate(listOf(newer)) - // Then — message at 200 still outside the floor - assertEquals(listOf(floorMsg), sut.pendingMessagesInRange.value) - } - - @Test - fun `no-op when message list is empty`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - // When — floor remains null - sut.advanceOldestLoadedDate(emptyList()) - // Then — floor is still null, so no filter applied - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region setNewestLoadedDate - - @Nested - inner class SetNewestLoadedDate { - - @Test - fun `sets ceiling and excludes messages above it`() { - // Given - sut.setEnabled(true) - val ceiling = Date(2000L) - val atCeiling = randomMessage(id = "m1", createdAt = ceiling, createdLocallyAt = null) - val aboveCeiling = randomMessage(id = "m2", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(atCeiling, aboveCeiling)) - // When - sut.setNewestLoadedDate(ceiling) - // Then - assertEquals(listOf(atCeiling), sut.pendingMessagesInRange.value) - } - - @Test - fun `null removes ceiling so all pending messages pass`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(5000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.setNewestLoadedDate(Date(1000L)) // ceiling blocks msg - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - // When - sut.setNewestLoadedDate(null) - // Then - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region advanceNewestLoadedDate - - @Nested - inner class AdvanceNewestLoadedDate { - - @Test - fun `first non-null call sets ceiling`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - // When — ceiling = 2000, msg at 3000 is above - sut.advanceNewestLoadedDate(Date(2000L)) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - @Test - fun `advances ceiling forward when date is newer`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.advanceNewestLoadedDate(Date(2000L)) // ceiling = 2000, msg hidden - // When — advance to 4000 - sut.advanceNewestLoadedDate(Date(4000L)) - // Then — msg at 3000 is now within range - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - - @Test - fun `does NOT advance ceiling backward`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.advanceNewestLoadedDate(Date(4000L)) // ceiling = 4000, msg visible - // When — try to retreat ceiling to 2000 - sut.advanceNewestLoadedDate(Date(2000L)) - // Then — msg still visible - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - - @Test - fun `null argument is a no-op`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(3000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.advanceNewestLoadedDate(Date(2000L)) // ceiling = 2000 - // When - sut.advanceNewestLoadedDate(null) - // Then — ceiling unchanged, msg still hidden - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - } - - // endregion - - // region reset - - @Nested - inner class Reset { - - @Test - fun `clears pending messages and date range`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.advanceOldestLoadedDate(listOf(msg)) - sut.setNewestLoadedDate(Date(5000L)) - // When - sut.reset() - // Then — messages cleared; null floor and ceiling means no messages to show anyway - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - @Test - fun `state can be repopulated after reset`() { - // Given - sut.setEnabled(true) - val msg = randomMessage(id = "m1", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(msg)) - sut.reset() - // When - sut.setPendingMessages(listOf(msg)) - // Then - assertEquals(listOf(msg), sut.pendingMessagesInRange.value) - } - } - - // endregion - - // region date filtering - - @Nested - inner class DateFiltering { - - @Test - fun `message at floor boundary is included`() { - // Given - sut.setEnabled(true) - val floor = Date(1000L) - val atFloor = randomMessage(id = "m1", createdAt = floor, createdLocallyAt = null) - sut.setPendingMessages(listOf(atFloor)) - sut.advanceOldestLoadedDate(listOf(randomMessage(id = "anchor", createdAt = floor, createdLocallyAt = null))) - // Then - assertEquals(listOf(atFloor), sut.pendingMessagesInRange.value) - } - - @Test - fun `message just below floor is excluded`() { - // Given - sut.setEnabled(true) - val justBelowFloor = randomMessage(id = "m1", createdAt = Date(999L), createdLocallyAt = null) - val floorMsg = randomMessage(id = "anchor", createdAt = Date(1000L), createdLocallyAt = null) - sut.setPendingMessages(listOf(justBelowFloor)) - sut.advanceOldestLoadedDate(listOf(floorMsg)) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - - @Test - fun `message at ceiling boundary is included`() { - // Given - sut.setEnabled(true) - val ceiling = Date(2000L) - val atCeiling = randomMessage(id = "m1", createdAt = ceiling, createdLocallyAt = null) - sut.setPendingMessages(listOf(atCeiling)) - sut.setNewestLoadedDate(ceiling) - // Then - assertEquals(listOf(atCeiling), sut.pendingMessagesInRange.value) - } - - @Test - fun `message just above ceiling is excluded`() { - // Given - sut.setEnabled(true) - val aboveCeiling = randomMessage(id = "m1", createdAt = Date(2001L), createdLocallyAt = null) - sut.setPendingMessages(listOf(aboveCeiling)) - sut.setNewestLoadedDate(Date(2000L)) - // Then - assertTrue(sut.pendingMessagesInRange.value.isEmpty()) - } - } - - // endregion -} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt index 81dcaa1048c..4cdc2bb623d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/message/MessageUtilsTest.kt @@ -37,6 +37,8 @@ import kotlinx.coroutines.test.currentTime import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.util.Date @@ -581,6 +583,54 @@ internal class MessageUtilsTest { Assertions.assertTrue(UuidRegex.matches(draftWithId.id)) } + @Test + fun `isLocalOnly returns true for SyncStatus SYNC_NEEDED`() { + val message = randomMessage(syncStatus = SyncStatus.SYNC_NEEDED, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for SyncStatus IN_PROGRESS`() { + val message = randomMessage(syncStatus = SyncStatus.IN_PROGRESS, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for SyncStatus AWAITING_ATTACHMENTS`() { + val message = randomMessage(syncStatus = SyncStatus.AWAITING_ATTACHMENTS, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for SyncStatus FAILED_PERMANENTLY`() { + val message = randomMessage(syncStatus = SyncStatus.FAILED_PERMANENTLY, type = MessageType.REGULAR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for type ephemeral with COMPLETED syncStatus`() { + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.EPHEMERAL) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns true for type error with COMPLETED syncStatus`() { + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.ERROR) + assertTrue(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns false for SyncStatus COMPLETED with type regular`() { + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.REGULAR) + assertFalse(message.isLocalOnly()) + } + + @Test + fun `isLocalOnly returns false for system message with COMPLETED`() { + val message = randomMessage(syncStatus = SyncStatus.COMPLETED, type = MessageType.SYSTEM) + assertFalse(message.isLocalOnly()) + } + private companion object { // Regex matching lowercase UUID format From c1533ef933f0dd4bae20a6a9b0e0d714b4cfd730 Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 16 Mar 2026 14:39:32 +0100 Subject: [PATCH 20/22] Improve handling of pending and local-only messages in ChannelState. --- .../api/stream-chat-android-client.api | 2 +- .../domain/channel/internal/ChannelEntity.kt | 1 - .../internal/DatabaseMessageRepository.kt | 17 +++++----------- .../domain/message/internal/MessageDao.kt | 5 +++-- .../channel/internal/ChannelLogicImpl.kt | 3 ++- .../internal/MessagesPaginationState.kt | 3 +-- .../client/utils/message/MessageUtils.kt | 20 ++++++++++++------- 7 files changed, 25 insertions(+), 26 deletions(-) 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 218ba735b74..62d0bd7405e 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -3188,13 +3188,13 @@ public final class io/getstream/chat/android/client/internal/offline/repository/ public fun select (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectByCidAndUserId (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectBySyncStatus (Lio/getstream/chat/android/models/SyncStatus;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun selectBySyncStatusOrTypeForChannel (Ljava/lang/String;Ljava/util/List;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectByUserId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectChunked (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectDraftMessageByCid (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectDraftMessageByParentId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectDraftMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectIdsBySyncStatus (Lio/getstream/chat/android/models/SyncStatus;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun selectLocalOnlyForChannel (Ljava/lang/String;Ljava/util/List;Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectMessagesWithPoll (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun selectWaitForAttachments (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun updateMessageInnerEntity (Lio/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageInnerEntity;)V diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt index b0ebc901c20..794fbf05dd2 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/channel/internal/ChannelEntity.kt @@ -87,7 +87,6 @@ internal data class ChannelEntity( val membership: MemberEntity?, val activeLiveLocations: List, val messageCount: Int?, - val oldestLoadedDate: Date? = null, ) { /** * The channel id in the format messaging:123. diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt index 838d4389f2e..4ec0f280f43 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/DatabaseMessageRepository.kt @@ -21,10 +21,11 @@ import io.getstream.chat.android.client.api.models.Pagination import io.getstream.chat.android.client.internal.offline.extensions.launchWithMutex import io.getstream.chat.android.client.persistance.repository.MessageRepository import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest +import io.getstream.chat.android.client.utils.message.LocalOnlyMessageTypes +import io.getstream.chat.android.client.utils.message.LocalOnlySyncStatuses import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.Poll import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User @@ -264,18 +265,10 @@ internal class DatabaseMessageRepository( } override suspend fun selectLocalOnlyMessagesForChannel(cid: String): List = - messageDao.selectLocalOnlyForChannel( + messageDao.selectBySyncStatusOrTypeForChannel( cid = cid, - syncStatuses = listOf( - SyncStatus.SYNC_NEEDED.status, // -1 - SyncStatus.IN_PROGRESS.status, // 3 - SyncStatus.AWAITING_ATTACHMENTS.status, // 4 - SyncStatus.FAILED_PERMANENTLY.status, // 2 - ), - types = listOf( - MessageType.EPHEMERAL, // "ephemeral" - MessageType.ERROR, // "error" - ), + syncStatuses = LocalOnlySyncStatuses.map(SyncStatus::status), + types = LocalOnlyMessageTypes.toList(), ).map { entity -> entity.toMessage() } private suspend fun selectMessagesEntitiesForChannel( diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt index 7595aefe926..bce422d9d38 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/repository/domain/message/internal/MessageDao.kt @@ -256,10 +256,11 @@ internal interface MessageDao { @Query( "SELECT * FROM $MESSAGE_ENTITY_TABLE_NAME " + "WHERE cid = :cid " + - "AND (syncStatus IN (:syncStatuses) OR type IN (:types))", + "AND (syncStatus IN (:syncStatuses) OR type IN (:types)) " + + "ORDER BY CASE WHEN createdAt IS NULL THEN createdLocallyAt ELSE createdAt END ASC", ) @Transaction - suspend fun selectLocalOnlyForChannel( + suspend fun selectBySyncStatusOrTypeForChannel( cid: String, syncStatuses: List, types: List, 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 4b518377b1f..dd10ee50053 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 @@ -100,6 +100,7 @@ internal class ChannelLogicImpl( isNotificationUpdate = query.isNotificationUpdate, isChannelsStateUpdate = true, ) + // Set the currently known oldest message until online data is retrieved state.paginationManager.setOldestMessage(channel.messages.lastOrNull()) state.setLocalOnlyMessages(localOnlyMessages) } @@ -111,7 +112,7 @@ internal class ChannelLogicImpl( override fun onQueryChannelResult(query: QueryChannelRequest, result: Result) { val limit = query.messagesLimit() val isNotificationUpdate = query.isNotificationUpdate - // Update pagination/recovery state only if it's not a notification update + // Update pagination state only if it's not a notification update and the call was made for fetching messages // (from LoadNotificationDataWorker) and a limit is set (otherwise we are not loading messages) if (!isNotificationUpdate && limit != 0) { state.paginationManager.end(query, result) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt index 2d35d628fde..dc9ca2bda14 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/MessagesPaginationState.kt @@ -78,8 +78,7 @@ internal data class MessagesPaginationState( * Returns true if [Message] falls within the currently loaded pagination window. * * The floor is [oldestMessageAt] (null = no floor). The ceiling is [newestMessageAt] (null = at - * the latest page, no ceiling). Both properties are null-when-unbounded, so no additional - * flag check is required. + * the latest page, no ceiling). */ fun isInWindow(message: Message): Boolean { val date = message.getCreatedAtOrDefault(NEVER) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt index 1e283c7d8e2..68e9f67fad8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/utils/message/MessageUtils.kt @@ -240,10 +240,16 @@ internal fun fallbackMessageId(): String = UUID.randomUUID().toString().lowercas * DOES NOT include COMPLETED messages — those are already in the server response. */ internal fun Message.isLocalOnly(): Boolean = - syncStatus in setOf( - SyncStatus.SYNC_NEEDED, // new message or pending edit/delete - SyncStatus.IN_PROGRESS, // send in flight - SyncStatus.AWAITING_ATTACHMENTS, // attachment upload pending - SyncStatus.FAILED_PERMANENTLY, // permanent failure — user must see to retry - ) || type == MessageType.EPHEMERAL || // Giphy preview etc. — not re-delivered by server - type == MessageType.ERROR // error type — not re-delivered by server + syncStatus in LocalOnlySyncStatuses || type in LocalOnlyMessageTypes + +internal val LocalOnlySyncStatuses = setOf( + SyncStatus.SYNC_NEEDED, // new message or pending edit/delete + SyncStatus.IN_PROGRESS, // send in flight + SyncStatus.AWAITING_ATTACHMENTS, // attachment upload pending + SyncStatus.FAILED_PERMANENTLY, // permanent failure — user must see to retry +) + +internal val LocalOnlyMessageTypes = setOf( + MessageType.EPHEMERAL, // giphy preview etc. — not re-delivered by server + MessageType.ERROR, // error type — not re-delivered by server +) From 7770f8b8e00966c4bfab2655c5be9388478d5a4d Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 16 Mar 2026 15:58:39 +0100 Subject: [PATCH 21/22] Add missing tests. --- .../channel/internal/ChannelLogicImplTest.kt | 34 ++++ .../ChannelStateImplLocalOnlyMessagesTest.kt | 182 ++++++++++++++++++ .../internal/ChannelStateImplMessagesTest.kt | 138 ++++++------- .../internal/ChannelStateImplTestBase.kt | 16 ++ 4 files changed, 305 insertions(+), 65 deletions(-) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalOnlyMessagesTest.kt 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 05427add8b2..fc278db54b8 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 @@ -678,6 +678,40 @@ internal class ChannelLogicImplTest { verify(repository).selectChannel(cid) verify(stateImpl).updateChannelData(any<(ChannelData?) -> ChannelData?>()) } + + @Test + fun `should set oldest message and local-only messages on state after loading from database`() = runTest { + // Given + val dbChannel = randomChannel(id = "123", type = "messaging", messages = emptyList()) + val messages = listOf( + randomMessage(id = "m1", createdAt = Date(1000)), + randomMessage(id = "m2", createdAt = Date(2000)), + ) + val localOnlyMessages = listOf(randomMessage(id = "lo1"), randomMessage(id = "lo2")) + val query = QueryChannelRequest().withMessages(30) + whenever(repository.selectChannel(cid)).thenReturn(dbChannel) + whenever(repository.selectMessagesForChannel(any(), any())).thenReturn(messages) + whenever(repository.selectLocalOnlyMessagesForChannel(cid)).thenReturn(localOnlyMessages) + // When + sut.updateStateFromDatabase(query) + // Then + verify(paginationManager).setOldestMessage(messages.last()) + verify(repository).selectLocalOnlyMessagesForChannel(cid) + verify(stateImpl).setLocalOnlyMessages(localOnlyMessages) + } + + @Test + fun `should set oldest message as null when no messages are loaded from database`() = runTest { + // Given + val dbChannel = randomChannel(id = "123", type = "messaging", messages = emptyList()) + val query = QueryChannelRequest().withMessages(30) + whenever(repository.selectChannel(cid)).thenReturn(dbChannel) + whenever(repository.selectMessagesForChannel(any(), any())).thenReturn(emptyList()) + // When + sut.updateStateFromDatabase(query) + // Then + verify(paginationManager).setOldestMessage(null) + } } // endregion diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalOnlyMessagesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalOnlyMessagesTest.kt new file mode 100644 index 00000000000..40c9d5e11c7 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplLocalOnlyMessagesTest.kt @@ -0,0 +1,182 @@ +/* + * 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.randomMessage +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.assertTrue +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.util.Date + +@ExperimentalCoroutinesApi +internal class ChannelStateImplLocalOnlyMessagesTest : ChannelStateImplTestBase() { + + // region setLocalOnlyMessages — visibility + + @Nested + inner class SetLocalOnlyMessages { + + @Test + fun `local-only message appears in messages flow after setLocalOnlyMessages`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `replacing with empty list removes previously visible local-only messages`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + channelState.setLocalOnlyMessages(emptyList()) + assertFalse(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `local-only message is merged before a later regular message`() = runTest { + val regular = createMessage(2, timestamp = 2000L) + val localOnly = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.setMessages(listOf(regular)) + channelState.setLocalOnlyMessages(listOf(localOnly)) + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.indexOf(localOnly.id) < ids.indexOf(regular.id)) + } + + @Test + fun `local-only message is merged after an earlier regular message`() = runTest { + val regular = createMessage(1, timestamp = 1000L) + val localOnly = createLocalOnlyMessage(2, timestamp = 2000L) + channelState.setMessages(listOf(regular)) + channelState.setLocalOnlyMessages(listOf(localOnly)) + val ids = channelState.messages.value.map { it.id } + assertTrue(ids.indexOf(regular.id) < ids.indexOf(localOnly.id)) + } + } + + // endregion + + // region floor / ceiling filtering + + @Nested + inner class WindowFiltering { + + @Test + fun `local-only message below oldest loaded date floor is not shown`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 500L) + val floor = randomMessage(id = "floor", createdAt = Date(1000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setOldestMessage(floor) + assertFalse(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `local-only message above newest loaded date ceiling is not shown`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 3000L) + val ceiling = randomMessage(id = "ceiling", createdAt = Date(2000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setNewestMessage(ceiling) + assertFalse(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `local-only message within both floor and ceiling is shown`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 2000L) + val floor = randomMessage(id = "f", createdAt = Date(1000L), createdLocallyAt = null) + val ceiling = randomMessage(id = "c", createdAt = Date(3000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setOldestMessage(floor) + channelState.paginationManager.setNewestMessage(ceiling) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `local-only message at exact floor boundary is shown`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 1000L) + val floor = randomMessage(id = "f", createdAt = Date(1000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setOldestMessage(floor) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `removing ceiling reveals previously hidden local-only message`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 5000L) + val ceiling = randomMessage(id = "c", createdAt = Date(1000L), createdLocallyAt = null) + channelState.setLocalOnlyMessages(listOf(localOnly)) + channelState.paginationManager.setNewestMessage(ceiling) + assertFalse(channelState.messages.value.any { it.id == localOnly.id }) + channelState.paginationManager.setNewestMessage(null) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + + @Test + fun `null floor means no floor restriction, all local-only messages are visible`() = runTest { + val localOnly = createLocalOnlyMessage(1, timestamp = 1L) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertTrue(channelState.messages.value.any { it.id == localOnly.id }) + } + } + + // endregion + + // region upsertMessage — ephemeral path + + @Nested + inner class UpsertMessageEphemeral { + + @Test + fun `upsertMessage with ephemeral message adds it to local-only messages`() = runTest { + val ephemeral = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.upsertMessage(ephemeral) + assertTrue(channelState.messages.value.any { it.id == ephemeral.id }) + } + + @Test + fun `upsertMessage with ephemeral message updates existing entry without duplicating it`() = runTest { + val ephemeral = createLocalOnlyMessage(1, timestamp = 1000L) + // Seed local-only state first, then update via upsertMessage + channelState.setLocalOnlyMessages(listOf(ephemeral)) + channelState.upsertMessage(ephemeral.copy(text = "Updated")) + val found = channelState.messages.value.find { it.id == ephemeral.id } + assertEquals("Updated", found?.text) + assertEquals(1, channelState.messages.value.count { it.id == ephemeral.id }) + } + } + + // endregion + + // region deleteMessage — local-only cleanup + + @Nested + inner class DeleteMessage { + + @Test + fun `deleteMessage removes ephemeral message from local-only messages`() = runTest { + val ephemeral = createLocalOnlyMessage(1, timestamp = 1000L) + channelState.setLocalOnlyMessages(listOf(ephemeral)) + assertTrue(channelState.messages.value.any { it.id == ephemeral.id }) + channelState.deleteMessage(ephemeral.id) + assertFalse(channelState.messages.value.any { it.id == ephemeral.id }) + } + } + + // endregion +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt index 20c354372e5..b5abf3f5129 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplMessagesTest.kt @@ -16,43 +16,25 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal -import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.User -import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.client.api.models.Pagination +import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.models.Config +import io.getstream.chat.android.models.MessagesState import io.getstream.chat.android.randomUser -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.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension import java.util.Date @ExperimentalCoroutinesApi -internal class ChannelStateImplMessagesTest { - - private val userFlow = MutableStateFlow(currentUser) - private lateinit var channelState: ChannelStateImpl - - @BeforeEach - fun setUp() { - channelState = ChannelStateImpl( - channelType = CHANNEL_TYPE, - channelId = CHANNEL_ID, - currentUser = userFlow, - latestUsers = MutableStateFlow(mapOf(currentUser.id to currentUser)), - mutedUsers = MutableStateFlow(emptyList()), - liveLocations = MutableStateFlow(emptyList()), - messageLimit = null, - ) - } +internal class ChannelStateImplMessagesTest : ChannelStateImplTestBase() { @Nested inner class SetMessages { @@ -717,52 +699,78 @@ internal class ChannelStateImplMessagesTest { } } - private fun createMessage( - index: Int, - timestamp: Long = currentTime() + index * 1000L, - text: String = "Test message $index", - user: User = currentUser, - parentId: String? = null, - showInChannel: Boolean = true, - shadowed: Boolean = false, - pinned: Boolean = false, - pinnedAt: Date? = null, - ): Message = randomMessage( - id = "message_$index", - cid = CID, - createdAt = Date(timestamp), - createdLocallyAt = null, - text = text, - user = user, - parentId = parentId, - showInChannel = showInChannel, - shadowed = shadowed, - pinned = pinned, - pinnedAt = pinnedAt, - deletedAt = null, - ) - - private fun createMessages( - count: Int, - startIndex: Int = 1, - baseTimestamp: Long = currentTime(), - ): List { - return (startIndex until startIndex + count).map { i -> - createMessage(i, timestamp = baseTimestamp + i * 1000L) + // region MessagesState + + @Nested + inner class MessagesStateTests { + + @Test + fun `messagesState is Loading when loading and messages are empty`() = runTest { + channelState.paginationManager.begin( + QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30), + ) + assertTrue(channelState.loading.value) + assertInstanceOf(MessagesState.Loading::class.java, channelState.messagesState.value) + } + + @Test + fun `messagesState is Result when loading but messages are non-empty`() = runTest { + channelState.setMessages(createMessages(3)) + channelState.paginationManager.begin( + QueryChannelRequest().withMessages(Pagination.LESS_THAN, "msgId", 30), + ) + assertTrue(channelState.loading.value) + assertInstanceOf(MessagesState.Result::class.java, channelState.messagesState.value) + } + + @Test + fun `messagesState is OfflineNoResults when not loading and messages are empty`() = runTest { + assertFalse(channelState.loading.value) + assertInstanceOf(MessagesState.OfflineNoResults::class.java, channelState.messagesState.value) } } - companion object { - @JvmField - @RegisterExtension - val testCoroutines = TestCoroutineExtension() + // endregion + + // region ThreeWayMerge — messages StateFlow combine branches - private const val CHANNEL_TYPE = "messaging" - private const val CHANNEL_ID = "123" - private const val CID = "messaging:123" + @Nested + inner class ThreeWayMerge { - private val currentUser = User(id = "tom", name = "Tom") + private fun enablePendingMessages() { + channelState.setChannelConfig(Config(markMessagesPending = true)) + } + + @Test + fun `given three sources, all messages appear in chronological order`() = runTest { + enablePendingMessages() + val regular = createMessage(2, timestamp = 2000L) + val pending = createMessage(1, timestamp = 1000L) + val localOnly = createLocalOnlyMessage(3, timestamp = 3000L) + channelState.setMessages(listOf(regular)) + channelState.setPendingMessages(listOf(pending)) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertEquals(listOf(pending.id, regular.id, localOnly.id), channelState.messages.value.map { it.id }) + } - private fun currentTime() = testCoroutines.dispatcher.scheduler.currentTime + @Test + fun `given two sources, pending and local-only, appear in chronological order`() = runTest { + enablePendingMessages() + val pending = createMessage(1, timestamp = 1000L) + val localOnly = createLocalOnlyMessage(2, timestamp = 2000L) + channelState.setPendingMessages(listOf(pending)) + channelState.setLocalOnlyMessages(listOf(localOnly)) + assertEquals(listOf(pending.id, localOnly.id), channelState.messages.value.map { it.id }) + } + + @Test + fun `given one source, regular is returned as-is when pending and local-only are empty`() = runTest { + val regular1 = createMessage(1, timestamp = 1000L) + val regular2 = createMessage(2, timestamp = 2000L) + channelState.setMessages(listOf(regular1, regular2)) + assertEquals(listOf(regular1.id, regular2.id), channelState.messages.value.map { it.id }) + } } + + // endregion } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplTestBase.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplTestBase.kt index b58fae3ceaa..9a28c319a40 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplTestBase.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplTestBase.kt @@ -17,6 +17,8 @@ package io.getstream.chat.android.client.internal.state.plugin.state.channel.internal import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User import io.getstream.chat.android.randomMessage import io.getstream.chat.android.test.TestCoroutineExtension @@ -87,6 +89,20 @@ internal abstract class ChannelStateImplTestBase { } } + protected fun createLocalOnlyMessage(index: Int, timestamp: Long): Message = + randomMessage( + id = "local_only_$index", + cid = CID, + createdAt = Date(timestamp), + createdLocallyAt = null, + syncStatus = SyncStatus.COMPLETED, + type = MessageType.EPHEMERAL, + parentId = null, + showInChannel = true, + shadowed = false, + deletedAt = null, + ) + companion object { @JvmField @RegisterExtension From 4ace31d43e17012fe6cc713ce9e47c4301282c5e Mon Sep 17 00:00:00 2001 From: VelikovPetar Date: Mon, 16 Mar 2026 18:19:39 +0100 Subject: [PATCH 22/22] Address CodeRabbit review remarks on test fixtures. - Fix ChannelLogicImplTest: stub messages now ordered DESC (newest first) to match MessageDao.messagesForChannel behaviour; assertion updated to verify setOldestMessage receives the oldest message (m1/1000ms). - Fix ChannelStateImplPendingMessagesTest: ceiling fixture id corrected from "floor" to "ceiling" to avoid confusion when assertions fail. Co-Authored-By: Claude Sonnet 4.6 --- .../logic/channel/internal/ChannelLogicImplTest.kt | 9 ++++----- .../internal/ChannelStateImplPendingMessagesTest.kt | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) 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 fc278db54b8..bd4c0bae4a2 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 @@ -683,10 +683,9 @@ internal class ChannelLogicImplTest { fun `should set oldest message and local-only messages on state after loading from database`() = runTest { // Given val dbChannel = randomChannel(id = "123", type = "messaging", messages = emptyList()) - val messages = listOf( - randomMessage(id = "m1", createdAt = Date(1000)), - randomMessage(id = "m2", createdAt = Date(2000)), - ) + val olderMessage = randomMessage(id = "m1", createdAt = Date(1000)) + val newerMessage = randomMessage(id = "m2", createdAt = Date(2000)) + val messages = listOf(newerMessage, olderMessage) val localOnlyMessages = listOf(randomMessage(id = "lo1"), randomMessage(id = "lo2")) val query = QueryChannelRequest().withMessages(30) whenever(repository.selectChannel(cid)).thenReturn(dbChannel) @@ -695,7 +694,7 @@ internal class ChannelLogicImplTest { // When sut.updateStateFromDatabase(query) // Then - verify(paginationManager).setOldestMessage(messages.last()) + verify(paginationManager).setOldestMessage(olderMessage) verify(repository).selectLocalOnlyMessagesForChannel(cid) verify(stateImpl).setLocalOnlyMessages(localOnlyMessages) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt index 7836a5341ca..5efa9fbe8bd 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/state/channel/internal/ChannelStateImplPendingMessagesTest.kt @@ -142,7 +142,7 @@ internal class ChannelStateImplPendingMessagesTest : ChannelStateImplTestBase() fun `pending message above newest loaded date ceiling is not shown`() { enablePendingMessages() val pending = createMessage(1, timestamp = 3000L) - val ceiling = randomMessage(id = "floor", createdAt = Date(2000L), createdLocallyAt = null) + val ceiling = randomMessage(id = "ceiling", createdAt = Date(2000L), createdLocallyAt = null) channelState.setPendingMessages(listOf(pending)) channelState.paginationManager.setNewestMessage(ceiling) assertFalse(channelState.messages.value.any { it.id == pending.id })