From 7d3e3de341826719b18586630395d5b3fd1a860a Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:11:42 +0200 Subject: [PATCH 1/2] client: Persist draft messages optimistically and on draft events --- .../chat/android/client/test/Mother.kt | 24 +++++++ .../chat/android/client/ChatClient.kt | 12 ++++ .../internal/DraftMessageListenerDatabase.kt | 56 ++++++++++----- .../internal/DatabaseMessageRepository.kt | 7 ++ .../domain/message/internal/MessageDao.kt | 6 ++ .../internal/EventHandlerSequential.kt | 7 ++ .../internal/DraftMessageListenerState.kt | 57 ++++++++++----- .../repository/MessageRepository.kt | 6 ++ .../repository/noop/NoOpMessageRepository.kt | 1 + .../chat/android/client/plugin/Plugin.kt | 16 +++++ .../plugin/listeners/DraftMessageListener.kt | 26 +++++++ .../client/ChatClientDraftsApiTests.kt | 43 ++++++++++++ .../DraftMessageListenerDatabaseTest.kt | 38 +++++----- .../UploadAttachmentsIntegrationTests.kt | 4 ++ .../internal/EventHandlerSequentialTest.kt | 69 +++++++++++++++++++ .../internal/DraftMessageListenerStateTest.kt | 38 +++++----- 16 files changed, 329 insertions(+), 81 deletions(-) diff --git a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt index cba3e6500cf..6b96dcdccec 100644 --- a/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt +++ b/stream-chat-android-client-test/src/main/java/io/getstream/chat/android/client/test/Mother.kt @@ -25,6 +25,8 @@ import io.getstream.chat.android.client.events.ChannelUpdatedEvent import io.getstream.chat.android.client.events.ChannelUserBannedEvent import io.getstream.chat.android.client.events.ChannelVisibleEvent import io.getstream.chat.android.client.events.ConnectedEvent +import io.getstream.chat.android.client.events.DraftMessageDeletedEvent +import io.getstream.chat.android.client.events.DraftMessageUpdatedEvent import io.getstream.chat.android.client.events.MarkAllReadEvent import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent @@ -63,6 +65,7 @@ import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateForm import io.getstream.chat.android.client.query.QueryChannelsSpec import io.getstream.chat.android.models.Answer import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.DraftMessage import io.getstream.chat.android.models.EventType import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Member @@ -81,6 +84,7 @@ import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomDate +import io.getstream.chat.android.randomDraftMessage import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMessage @@ -832,6 +836,26 @@ public fun randomPollDeletedEvent( ) } +public fun randomDraftMessageUpdatedEvent( + createdAt: Date = randomDate(), + draftMessage: DraftMessage = randomDraftMessage(), +): DraftMessageUpdatedEvent = DraftMessageUpdatedEvent( + type = EventType.DRAFT_MESSAGE_UPDATED, + createdAt = createdAt, + rawCreatedAt = streamFormatter.format(createdAt), + draftMessage = draftMessage, +) + +public fun randomDraftMessageDeletedEvent( + createdAt: Date = randomDate(), + draftMessage: DraftMessage = randomDraftMessage(), +): DraftMessageDeletedEvent = DraftMessageDeletedEvent( + type = EventType.DRAFT_MESSAGE_DELETED, + createdAt = createdAt, + rawCreatedAt = streamFormatter.format(createdAt), + draftMessage = draftMessage, +) + public fun randomPollUpdatedEvent( createdAt: Date = randomDate(), cid: String = randomCID(), diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index 60e03d8b2ae..1c994225231 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -2880,6 +2880,12 @@ internal constructor( return message.ensureId().let { processedDraftMessage -> api.createDraftMessage(channelType, channelId, processedDraftMessage) .retry(userScope, retryPolicy) + .doOnStart(userScope) { + logger.v { "[createDraftMessage] #doOnStart; cid: $channelType:$channelId" } + plugins.forEach { listener -> + listener.onCreateDraftMessageRequest(channelType, channelId, processedDraftMessage) + } + } .doOnResult(userScope) { result -> logger.i { "[createDraftMessage] result: ${result.stringify { it.toString() }}" } plugins.forEach { listener -> @@ -2908,6 +2914,12 @@ internal constructor( ): Call { return api.deleteDraftMessage(channelType, channelId, message) .retry(userScope, retryPolicy) + .doOnStart(userScope) { + logger.v { "[deleteDraftMessages] #doOnStart; cid: $channelType:$channelId" } + plugins.forEach { listener -> + listener.onDeleteDraftMessagesRequest(channelType, channelId, message) + } + } .doOnResult(userScope) { result -> logger.i { "[deleteDraftMessages] result: ${result.stringify { it.toString() }}" } plugins.forEach { listener -> diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DraftMessageListenerDatabase.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DraftMessageListenerDatabase.kt index d20419054f2..a52803cea29 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DraftMessageListenerDatabase.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DraftMessageListenerDatabase.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.client.internal.offline.plugin.listener.internal -import io.getstream.chat.android.client.errors.isPermanent import io.getstream.chat.android.client.persistance.repository.MessageRepository import io.getstream.chat.android.client.plugin.listeners.DraftMessageListener import io.getstream.chat.android.models.DraftMessage @@ -25,7 +24,6 @@ import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.QueryDraftsResult import io.getstream.chat.android.models.querysort.QuerySorter import io.getstream.result.Result -import io.getstream.result.onErrorSuspend import io.getstream.result.onSuccessSuspend internal class DraftMessageListenerDatabase( @@ -33,7 +31,24 @@ internal class DraftMessageListenerDatabase( ) : DraftMessageListener { /** - * Method called when a request to create a draft message in the API happens + * Method called before the request to create a draft message in the API is launched. Persists the draft upfront so + * it is not lost if the process dies while the request is in flight. + * + * @param channelType The type of the channel + * @param channelId The id of the channel + * @param message The draft message to be created + */ + override suspend fun onCreateDraftMessageRequest( + channelType: String, + channelId: String, + message: DraftMessage, + ) { + messageRepository.insertDraftMessage(message) + } + + /** + * Method called when a request to create a draft message in the API happens. Replaces the draft persisted by + * [onCreateDraftMessageRequest] with the server copy, leaving it untouched on failure. * * @param result The result of the create draft message request * @param channelType The type of the channel @@ -46,17 +61,28 @@ internal class DraftMessageListenerDatabase( channelId: String, message: DraftMessage, ) { - result - .onSuccessSuspend { draftMessage -> messageRepository.insertDraftMessage(draftMessage) } - .onErrorSuspend { error -> - message.takeUnless { error.isPermanent() }?.let { draftMessage -> - messageRepository.insertDraftMessage(draftMessage) - } - } + result.onSuccessSuspend { draftMessage -> messageRepository.insertDraftMessage(draftMessage) } } /** - * Method called when a request to delete draft messages in the API happens + * Method called before the request to delete draft messages in the API is launched. Removes the draft upfront so it + * stays deleted if the process dies while the request is in flight. + * + * @param channelType The type of the channel + * @param channelId The id of the channel + * @param message The draft message to be deleted + */ + override suspend fun onDeleteDraftMessagesRequest( + channelType: String, + channelId: String, + message: DraftMessage, + ) { + messageRepository.deleteDraftMessage(message) + } + + /** + * Method called when a request to delete draft messages in the API happens. No-op, as the draft is already removed + * by [onDeleteDraftMessagesRequest]. * * @param result The result of the delete draft messages request * @param channelType The type of the channel @@ -69,13 +95,7 @@ internal class DraftMessageListenerDatabase( channelId: String, message: DraftMessage, ) { - result - .onSuccessSuspend { messageRepository.deleteDraftMessage(message) } - .onErrorSuspend { error -> - message.takeUnless { error.isPermanent() }?.let { draftMessage -> - messageRepository.deleteDraftMessage(draftMessage) - } - } + /* No-Op */ } /** 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 4dbfe29972c..8b1277bc362 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 @@ -172,6 +172,13 @@ internal class DatabaseMessageRepository( messageDao.deleteDraftMessage(message.id) } + override suspend fun deleteDraftMessage(cid: String, parentId: String?) { + when (parentId) { + null -> messageDao.deleteDraftMessageByCid(cid) + else -> messageDao.deleteDraftMessageByParentId(parentId) + } + } + override suspend fun selectDraftMessages(): List = messageDao.selectDraftMessages() .map { it.toModel(::selectMessage) } 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 00e8af420f8..61c039ab71b 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 @@ -97,6 +97,12 @@ internal interface MessageDao { @Query("DELETE FROM $DRAFT_MESSAGE_ENTITY_TABLE_NAME WHERE id = :messageId") suspend fun deleteDraftMessage(messageId: String) + @Query("DELETE FROM $DRAFT_MESSAGE_ENTITY_TABLE_NAME WHERE cid = :cid AND parentId IS NULL") + suspend fun deleteDraftMessageByCid(cid: String) + + @Query("DELETE FROM $DRAFT_MESSAGE_ENTITY_TABLE_NAME WHERE parentId = :parentId") + suspend fun deleteDraftMessageByParentId(parentId: String) + @Query( "SELECT * from $MESSAGE_ENTITY_TABLE_NAME " + "WHERE cid = :cid " + diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt index 6eb32d267f9..4c03048d16d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequential.kt @@ -929,6 +929,13 @@ internal class EventHandlerSequential( is PollDeletedEvent -> { repos.deletePoll(event.poll.id) } + is DraftMessageUpdatedEvent -> { + repos.insertDraftMessage(event.draftMessage) + } + is DraftMessageDeletedEvent -> { + // The event carries an empty message id, so the draft is identified by its channel/thread. + repos.deleteDraftMessage(event.draftMessage.cid, event.draftMessage.parentId) + } is UserMessagesDeletedEvent -> { deleteMessagesFromUser(event.cid, event.user.id, event.hardDelete, event.createdAt) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DraftMessageListenerState.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DraftMessageListenerState.kt index 767b4816d2c..436219c9c25 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DraftMessageListenerState.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DraftMessageListenerState.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.client.internal.state.plugin.listener.internal -import io.getstream.chat.android.client.errors.isPermanent import io.getstream.chat.android.client.internal.state.plugin.state.global.internal.MutableGlobalState import io.getstream.chat.android.client.plugin.listeners.DraftMessageListener import io.getstream.chat.android.models.DraftMessage @@ -34,8 +33,24 @@ internal class DraftMessageListenerState( ) : DraftMessageListener { /** - * Keeps a reference of the [DraftMessage] in the [MutableGlobalState] when the request to create a draft message - * is successful. + * Keeps a reference of the [DraftMessage] in the [MutableGlobalState] before the request to create a draft message + * is launched, so it shows up right away. + * + * @param channelType The type of the channel in which message is created. + * @param channelId The id of the the channel in which message is created. + * @param message [DraftMessage] to be created. + */ + override suspend fun onCreateDraftMessageRequest( + channelType: String, + channelId: String, + message: DraftMessage, + ) { + mutableGlobalState.updateDraftMessage(message) + } + + /** + * Replaces the reference of the [DraftMessage] in the [MutableGlobalState] with the server copy when the request to + * create a draft message is successful, leaving it untouched on failure. * * @param result [Result] response from the original request. * @param channelType The type of the channel in which message is created. @@ -48,18 +63,28 @@ internal class DraftMessageListenerState( channelId: String, message: DraftMessage, ) { - result - .onSuccess { draftMessage -> mutableGlobalState.updateDraftMessage(draftMessage) } - .onError { error -> - message.takeUnless { error.isPermanent() }?.let { draftMessage -> - mutableGlobalState.updateDraftMessage(draftMessage) - } - } + result.onSuccess { draftMessage -> mutableGlobalState.updateDraftMessage(draftMessage) } } /** - * Removes the reference of the [DraftMessage] from the [MutableGlobalState] when the request to delete - * a draft message is successful. + * Removes the reference of the [DraftMessage] from the [MutableGlobalState] before the request to delete a draft + * message is launched, so it disappears right away. + * + * @param channelType The type of the channel in which message is updated. + * @param channelId The id of the the channel in which message is updated. + * @param message [DraftMessage] to be deleted. + */ + override suspend fun onDeleteDraftMessagesRequest( + channelType: String, + channelId: String, + message: DraftMessage, + ) { + mutableGlobalState.removeDraftMessage(message) + } + + /** + * Method called when a request to delete draft messages in the API happens. No-op, as the draft is already removed + * by [onDeleteDraftMessagesRequest]. * * @param result [Result] response from the original request. * @param channelType The type of the channel in which message is updated. @@ -72,13 +97,7 @@ internal class DraftMessageListenerState( channelId: String, message: DraftMessage, ) { - result - .onSuccess { mutableGlobalState.removeDraftMessage(message) } - .onError { error -> - message.takeUnless { error.isPermanent() }?.let { draftMessage -> - mutableGlobalState.removeDraftMessage(draftMessage) - } - } + /* No-Op */ } /** 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 64d86dd36ea..d27915ca3e8 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 @@ -177,6 +177,12 @@ public interface MessageRepository { */ public suspend fun deleteDraftMessage(message: DraftMessage) + /** + * Deletes the draft message of a channel, or of a thread when [parentId] is not null. Used when the draft is only + * identified by the channel/thread it belongs to, as happens with the `draft.deleted` event. + */ + public suspend fun deleteDraftMessage(cid: String, parentId: String?) + /** * Evict messages from the repository. */ 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 5adaaccda6c..a06218ab0e1 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 @@ -41,6 +41,7 @@ internal object NoOpMessageRepository : MessageRepository { override suspend fun selectMessageBySyncState(syncStatus: SyncStatus): List = emptyList() override suspend fun selectMessagesWithPoll(pollId: String): List = emptyList() override suspend fun deleteDraftMessage(message: DraftMessage) { /* No-Op */ } + override suspend fun deleteDraftMessage(cid: String, parentId: String?) { /* No-Op */ } override suspend fun selectDraftMessages(): List = emptyList() override suspend fun selectDraftMessagesByCid(cid: String): DraftMessage? = null override suspend fun selectDraftMessageByParentId(parentId: String): DraftMessage? = null diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt index 83740a229ae..30df74555df 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/Plugin.kt @@ -446,6 +446,14 @@ public interface Plugin : /* No-Op */ } + override suspend fun onCreateDraftMessageRequest( + channelType: String, + channelId: String, + message: DraftMessage, + ) { + /* No-Op */ + } + override suspend fun onCreateDraftMessageResult( result: Result, channelType: String, @@ -455,6 +463,14 @@ public interface Plugin : /* No-Op */ } + override suspend fun onDeleteDraftMessagesRequest( + channelType: String, + channelId: String, + message: DraftMessage, + ) { + /* No-Op */ + } + override suspend fun onDeleteDraftMessagesResult( result: Result, channelType: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/DraftMessageListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/DraftMessageListener.kt index 4093ea58baa..f56b246b860 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/DraftMessageListener.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/DraftMessageListener.kt @@ -30,6 +30,19 @@ import io.getstream.result.Result @InternalStreamChatApi public interface DraftMessageListener { + /** + * Side effect to be invoked before the original request is launched. + * + * @param channelType The type of the channel in which message is created. + * @param channelId The id of the the channel in which message is created. + * @param message [DraftMessage] to be created. + */ + public suspend fun onCreateDraftMessageRequest( + channelType: String, + channelId: String, + message: DraftMessage, + ) + /** * Side effect to be invoked when the original request is completed with a response. * @@ -45,6 +58,19 @@ public interface DraftMessageListener { message: DraftMessage, ) + /** + * Side effect to be invoked before the original request is launched. + * + * @param channelType The type of the channel in which message is updated. + * @param channelId The id of the the channel in which message is updated. + * @param message [DraftMessage] to be deleted. + */ + public suspend fun onDeleteDraftMessagesRequest( + channelType: String, + channelId: String, + message: DraftMessage, + ) + /** * Side effect to be invoked when the original request is completed with a response. * diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientDraftsApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientDraftsApiTests.kt index ed4fd62b63c..fd610739334 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientDraftsApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientDraftsApiTests.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client import io.getstream.chat.android.client.chatclient.BaseChatClientTest +import io.getstream.chat.android.client.plugin.Plugin import io.getstream.chat.android.client.utils.RetroError import io.getstream.chat.android.client.utils.RetroSuccess import io.getstream.chat.android.client.utils.verifyNetworkError @@ -33,6 +34,10 @@ import org.junit.jupiter.api.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever /** @@ -40,6 +45,44 @@ import org.mockito.kotlin.whenever */ internal class ChatClientDraftsApiTests : BaseChatClientTest() { + @Test + fun createDraftNotifiesPluginsBeforeTheResponseArrives() = runTest { + // given + val channelType = randomString() + val channelId = randomString() + val draft = randomDraftMessage() + val plugin = mock() + plugins.add(plugin) + whenever(api.createDraftMessage(any(), any(), any())) + .doReturn(RetroSuccess(draft).toRetrofitCall()) + // when + chatClient.createDraftMessage(channelType, channelId, draft).await() + // then + inOrder(plugin).apply { + verify(plugin).onCreateDraftMessageRequest(eq(channelType), eq(channelId), any()) + verify(plugin).onCreateDraftMessageResult(any(), eq(channelType), eq(channelId), any()) + } + } + + @Test + fun deleteDraftNotifiesPluginsBeforeTheResponseArrives() = runTest { + // given + val channelType = randomString() + val channelId = randomString() + val draft = randomDraftMessage() + val plugin = mock() + plugins.add(plugin) + whenever(api.deleteDraftMessage(any(), any(), any())) + .doReturn(RetroSuccess(Unit).toRetrofitCall()) + // when + chatClient.deleteDraftMessages(channelType, channelId, draft).await() + // then + inOrder(plugin).apply { + verify(plugin).onDeleteDraftMessagesRequest(eq(channelType), eq(channelId), eq(draft)) + verify(plugin).onDeleteDraftMessagesResult(any(), eq(channelType), eq(channelId), eq(draft)) + } + } + @Test fun createDraftSuccess() = runTest { // given diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DraftMessageListenerDatabaseTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DraftMessageListenerDatabaseTest.kt index 14926abe970..1f55fcbb021 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DraftMessageListenerDatabaseTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/plugin/listener/internal/DraftMessageListenerDatabaseTest.kt @@ -21,7 +21,6 @@ import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.QueryDraftsResult import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.randomDraftMessage -import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomString import io.getstream.result.Error import io.getstream.result.Result @@ -33,7 +32,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify -import java.net.UnknownHostException internal class DraftMessageListenerDatabaseTest { @@ -46,11 +44,10 @@ internal class DraftMessageListenerDatabaseTest { } @Test - fun `onCreateDraftMessageResult should update state on success`() = runTest { + fun `onCreateDraftMessageRequest should persist the draft before the request completes`() = runTest { val draftMessage = randomDraftMessage() - listener.onCreateDraftMessageResult( - result = Result.Success(draftMessage), + listener.onCreateDraftMessageRequest( channelType = randomString(), channelId = randomString(), message = draftMessage, @@ -60,11 +57,11 @@ internal class DraftMessageListenerDatabaseTest { } @Test - fun `onCreateDraftMessageResult with a non permanent error should update state`() = runTest { + fun `onCreateDraftMessageResult should update state on success`() = runTest { val draftMessage = randomDraftMessage() listener.onCreateDraftMessageResult( - result = Result.Failure(Error.NetworkError(randomString(), randomInt(), cause = UnknownHostException())), + result = Result.Success(draftMessage), channelType = randomString(), channelId = randomString(), message = draftMessage, @@ -74,23 +71,25 @@ internal class DraftMessageListenerDatabaseTest { } @Test - fun `onCreateDraftMessageResult should not update state on error`() = runTest { + fun `onCreateDraftMessageResult should keep the persisted draft on any error`() = runTest { + val draftMessage = randomDraftMessage() + listener.onCreateDraftMessageResult( result = Result.Failure(Error.NetworkError(message = randomString(), 404)), channelType = randomString(), channelId = randomString(), - message = randomDraftMessage(), + message = draftMessage, ) + verify(messageRepository, never()).deleteDraftMessage(any()) verify(messageRepository, never()).insertDraftMessage(any()) } @Test - fun `onDeleteDraftMessagesResult should remove message from state on success`() = runTest { + fun `onDeleteDraftMessagesRequest should remove the draft before the request completes`() = runTest { val draftMessage = randomDraftMessage() - listener.onDeleteDraftMessagesResult( - result = Result.Success(Unit), + listener.onDeleteDraftMessagesRequest( channelType = randomString(), channelId = randomString(), message = draftMessage, @@ -100,25 +99,20 @@ internal class DraftMessageListenerDatabaseTest { } @Test - fun `onDeleteDraftMessagesResult with a non permanent error should remove message from state`() = runTest { + fun `onDeleteDraftMessagesResult should not touch storage on any outcome`() = runTest { val draftMessage = randomDraftMessage() + listener.onDeleteDraftMessagesResult( - result = Result.Failure(Error.NetworkError(randomString(), randomInt(), cause = UnknownHostException())), + result = Result.Failure(Error.NetworkError(message = randomString(), 404)), channelType = randomString(), channelId = randomString(), message = draftMessage, ) - - verify(messageRepository).deleteDraftMessage(draftMessage) - } - - @Test - fun `onDeleteDraftMessagesResult should not remove message from state on error`() = runTest { listener.onDeleteDraftMessagesResult( - result = Result.Failure(Error.NetworkError(message = randomString(), 404)), + result = Result.Success(Unit), channelType = randomString(), channelId = randomString(), - message = randomDraftMessage(), + message = draftMessage, ) verify(messageRepository, never()).deleteDraftMessage(any()) 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 21662d9b082..66f4daca3c5 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 @@ -305,6 +305,10 @@ internal class MockMessageRepository : MessageRepository { TODO("Not yet implemented") } + override suspend fun deleteDraftMessage(cid: String, parentId: String?) { + TODO("Not yet implemented") + } + override suspend fun deletePoll(pollId: String) { // No-op } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt index 7b4a883481d..323d60c77fe 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/event/handler/internal/EventHandlerSequentialTest.kt @@ -30,6 +30,8 @@ import io.getstream.chat.android.client.setup.state.ClientState import io.getstream.chat.android.client.test.randomChannelDeletedEvent import io.getstream.chat.android.client.test.randomChannelUpdatedEvent import io.getstream.chat.android.client.test.randomConnectedEvent +import io.getstream.chat.android.client.test.randomDraftMessageDeletedEvent +import io.getstream.chat.android.client.test.randomDraftMessageUpdatedEvent import io.getstream.chat.android.client.test.randomMarkAllReadEvent import io.getstream.chat.android.client.test.randomMessageUpdateEvent import io.getstream.chat.android.client.test.randomNewMessageEvent @@ -55,6 +57,7 @@ import io.getstream.chat.android.randomBoolean import io.getstream.chat.android.randomCID import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomChannelMute +import io.getstream.chat.android.randomDraftMessage import io.getstream.chat.android.randomLocation import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMessage @@ -325,6 +328,72 @@ internal class EventHandlerSequentialTest { mutableGlobalState.blockedUserIds.value `should be equal to` expectedBlockedUserIds } + @Test + fun `When handling DraftMessageDeletedEvent, The draft should be deleted from local storage`() = runTest { + // given + val storedDraft = randomDraftMessage(parentId = null) + // The backend sends an empty message id on draft.deleted, so only the cid identifies the draft. + val event = randomDraftMessageDeletedEvent(draftMessage = storedDraft.copy(id = "", text = "")) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn emptyList() + whenever(repos.selectThreads(any())) doReturn emptyList() + val mutableGlobalState = MutableGlobalState(currentUser.id).apply { + updateDraftMessage(storedDraft) + } + val handler = Fixture() + .withRepositoryFacade(repos) + .withMutableGlobalState(mutableGlobalState) + .get(this) + // when + handler.handleEvents(event) + // then + mutableGlobalState.channelDraftMessages.value `should be equal to` emptyMap() + verify(repos).deleteDraftMessage(storedDraft.cid, null) + } + + @Test + fun `When handling DraftMessageDeletedEvent for a thread, The draft should be deleted from local storage`() = + runTest { + // given + val parentId = randomString() + val storedDraft = randomDraftMessage(parentId = parentId) + val event = randomDraftMessageDeletedEvent(draftMessage = storedDraft.copy(id = "", text = "")) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn emptyList() + whenever(repos.selectThreads(any())) doReturn emptyList() + val handler = Fixture() + .withRepositoryFacade(repos) + .withMutableGlobalState(MutableGlobalState(currentUser.id)) + .get(this) + // when + handler.handleEvents(event) + // then + verify(repos).deleteDraftMessage(storedDraft.cid, parentId) + } + + @Test + fun `When handling DraftMessageUpdatedEvent, The draft should be stored in local storage`() = runTest { + // given + val draftMessage = randomDraftMessage(parentId = null) + val event = randomDraftMessageUpdatedEvent(draftMessage = draftMessage) + val repos: RepositoryFacade = mock() + whenever(repos.selectMessages(any())) doReturn emptyList() + whenever(repos.selectChannels(any())) doReturn emptyList() + whenever(repos.selectThreads(any())) doReturn emptyList() + val mutableGlobalState = MutableGlobalState(currentUser.id) + val handler = Fixture() + .withRepositoryFacade(repos) + .withMutableGlobalState(mutableGlobalState) + .get(this) + // when + handler.handleEvents(event) + // then + mutableGlobalState.channelDraftMessages.value `should be equal to` mapOf(draftMessage.cid to draftMessage) + verify(repos).insertDraftMessage(draftMessage) + } + @Test fun `When handling PollDeletedEvent, The the poll should be deleted from local storage`() = runTest { // given diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DraftMessageListenerStateTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DraftMessageListenerStateTest.kt index f88b0de3ceb..4ed3f6890ce 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DraftMessageListenerStateTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/state/plugin/listener/internal/DraftMessageListenerStateTest.kt @@ -29,7 +29,6 @@ import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify -import java.net.UnknownHostException internal class DraftMessageListenerStateTest { @@ -37,11 +36,10 @@ internal class DraftMessageListenerStateTest { private val listener = DraftMessageListenerState(mutableGlobalState) @Test - fun `onCreateDraftMessageResult should update state on success`() = runTest { + fun `onCreateDraftMessageRequest should update state before the request completes`() = runTest { val draftMessage = randomDraftMessage() - listener.onCreateDraftMessageResult( - result = Result.Success(draftMessage), + listener.onCreateDraftMessageRequest( channelType = randomString(), channelId = randomString(), message = draftMessage, @@ -51,11 +49,11 @@ internal class DraftMessageListenerStateTest { } @Test - fun `onCreateDraftMessageResult with a non permanent error should update state`() = runTest { + fun `onCreateDraftMessageResult should update state on success`() = runTest { val draftMessage = randomDraftMessage() listener.onCreateDraftMessageResult( - result = Result.Failure(Error.NetworkError(randomString(), randomInt(), cause = UnknownHostException())), + result = Result.Success(draftMessage), channelType = randomString(), channelId = randomString(), message = draftMessage, @@ -65,23 +63,24 @@ internal class DraftMessageListenerStateTest { } @Test - fun `onCreateDraftMessageResult should not update state on error`() = runTest { + fun `onCreateDraftMessageResult should keep the draft in state on any error`() = runTest { + val draftMessage = randomDraftMessage() + listener.onCreateDraftMessageResult( result = Result.Failure(Error.NetworkError(message = randomString(), 404)), channelType = randomString(), channelId = randomString(), - message = randomDraftMessage(), + message = draftMessage, ) - verify(mutableGlobalState, never()).updateDraftMessage(any()) + verify(mutableGlobalState, never()).removeDraftMessage(any()) } @Test - fun `onDeleteDraftMessagesResult should remove message from state on success`() = runTest { + fun `onDeleteDraftMessagesRequest should remove message from state before the request completes`() = runTest { val draftMessage = randomDraftMessage() - listener.onDeleteDraftMessagesResult( - result = Result.Success(Unit), + listener.onDeleteDraftMessagesRequest( channelType = randomString(), channelId = randomString(), message = draftMessage, @@ -91,25 +90,20 @@ internal class DraftMessageListenerStateTest { } @Test - fun `onDeleteDraftMessagesResult with a non permanent error should remove message from state`() = runTest { + fun `onDeleteDraftMessagesResult should not touch state on any outcome`() = runTest { val draftMessage = randomDraftMessage() + listener.onDeleteDraftMessagesResult( - result = Result.Failure(Error.NetworkError(randomString(), randomInt(), cause = UnknownHostException())), + result = Result.Failure(Error.NetworkError(message = randomString(), 404)), channelType = randomString(), channelId = randomString(), message = draftMessage, ) - - verify(mutableGlobalState).removeDraftMessage(draftMessage) - } - - @Test - fun `onDeleteDraftMessagesResult should not remove message from state on error`() = runTest { listener.onDeleteDraftMessagesResult( - result = Result.Failure(Error.NetworkError(message = randomString(), 404)), + result = Result.Success(Unit), channelType = randomString(), channelId = randomString(), - message = randomDraftMessage(), + message = draftMessage, ) verify(mutableGlobalState, never()).removeDraftMessage(any()) From f699321606f918d48626141fe3e37d388524fc33 Mon Sep 17 00:00:00 2001 From: Gian <47775302+gpunto@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:39:29 +0200 Subject: [PATCH 2/2] client: Add unit tests for the new draft persistence code --- .../repository/DraftMessageRepositoryTest.kt | 96 +++++++++++++++++++ .../client/plugin/ThrottlingPluginTest.kt | 12 +++ 2 files changed, 108 insertions(+) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/DraftMessageRepositoryTest.kt diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/DraftMessageRepositoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/DraftMessageRepositoryTest.kt new file mode 100644 index 00000000000..b67b67d3ba8 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/internal/offline/repository/DraftMessageRepositoryTest.kt @@ -0,0 +1,96 @@ +/* + * 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.offline.repository + +import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.DatabaseMessageRepository +import io.getstream.chat.android.client.internal.offline.repository.domain.message.internal.MessageDao +import io.getstream.chat.android.client.persistance.repository.MessageRepository +import io.getstream.chat.android.randomCID +import io.getstream.chat.android.randomDraftMessage +import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.test.TestCoroutineRule +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +internal class DraftMessageRepositoryTest { + + @get:Rule + val testCoroutines: TestCoroutineRule = TestCoroutineRule() + + private lateinit var messageDao: MessageDao + private lateinit var sut: MessageRepository + + @Before + fun before() { + messageDao = mock() + sut = DatabaseMessageRepository( + scope = testCoroutines.scope, + messageDao = messageDao, + replyMessageDao = mock(), + pollDao = mock(), + getUser = { randomUser() }, + currentUser = randomUser(), + ignoredChannelTypes = emptySet(), + ) + } + + @Test + fun `When deleting a draft message Should delete it by its id`() = runTest { + val draftMessage = randomDraftMessage() + + sut.deleteDraftMessage(draftMessage) + + verify(messageDao).deleteDraftMessage(draftMessage.id) + } + + @Test + fun `When deleting a channel draft message Should delete it by cid`() = runTest { + val cid = randomCID() + + sut.deleteDraftMessage(cid = cid, parentId = null) + + verify(messageDao).deleteDraftMessageByCid(cid) + verify(messageDao, never()).deleteDraftMessageByParentId(any()) + } + + @Test + fun `When deleting a thread draft message Should delete it by parent id`() = runTest { + val cid = randomCID() + val parentId = randomString() + + sut.deleteDraftMessage(cid = cid, parentId = parentId) + + verify(messageDao).deleteDraftMessageByParentId(parentId) + verify(messageDao, never()).deleteDraftMessageByCid(any()) + } + + @Test + fun `When inserting a draft message Should store it in the database`() = runTest { + val draftMessage = randomDraftMessage() + + sut.insertDraftMessage(draftMessage) + + verify(messageDao).insertDraftMessages(any()) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/ThrottlingPluginTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/ThrottlingPluginTest.kt index 02a861d77bc..fce24170f02 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/ThrottlingPluginTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/ThrottlingPluginTest.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.plugin import io.getstream.chat.android.models.Location +import io.getstream.chat.android.randomDraftMessage import io.getstream.chat.android.randomString import io.getstream.result.Result import kotlinx.coroutines.test.runTest @@ -34,6 +35,17 @@ internal class ThrottlingPluginTest { sut = ThrottlingPlugin(now = { currentTime }) } + @Test + fun `draft message callbacks are inert for a plugin that does not handle drafts`() = runTest { + val channelType = randomString() + val channelId = randomString() + val draftMessage = randomDraftMessage() + + // Exercises the No-Op defaults every Plugin inherits, so a plugin that ignores drafts cannot break them. + sut.onCreateDraftMessageRequest(channelType, channelId, draftMessage) + sut.onDeleteDraftMessagesRequest(channelType, channelId, draftMessage) + } + @Test fun `onUpdateLiveLocationPrecondition allows first call`() = runTest { val result = sut.onUpdateLiveLocationPrecondition(location = Location())