From 3c613351aaa48033c19baab51b8572b9faab910d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 22 Oct 2025 10:29:16 +0100 Subject: [PATCH 01/58] Add ChatClient.markMessagesAsDelivered function to mark messages as delivered --- .../api/stream-chat-android-client.api | 1 + .../chat/android/client/ChatClient.kt | 16 +++++++ .../chat/android/client/api/ChatApi.kt | 5 +++ .../chat/android/client/api2/MoshiChatApi.kt | 6 +++ .../client/api2/endpoint/ChannelApi.kt | 6 +++ .../model/requests/MarkDeliveredRequest.kt | 42 +++++++++++++++++++ .../client/ChatClientChannelApiTests.kt | 32 ++++++++++++++ .../android/client/api2/MoshiChatApiTest.kt | 28 +++++++++++++ .../client/api2/MoshiChatApiTestArguments.kt | 3 ++ 9 files changed, 139 insertions(+) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkDeliveredRequest.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 d538b9f6f5d..5ca9d225ad0 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -114,6 +114,7 @@ public final class io/getstream/chat/android/client/ChatClient { public static synthetic fun keystroke$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun markAllRead ()Lio/getstream/result/call/Call; public final fun markMessageRead (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; + public final fun markMessagesAsDelivered (Ljava/util/List;)Lio/getstream/result/call/Call; public final fun markRead (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markThreadRead (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markThreadUnread (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; 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 e0739366623..60ccfb2c803 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 @@ -2892,6 +2892,22 @@ internal constructor( } } + /** + * Marks the given messages as delivered. + * + * @param messages The list of messages to mark as delivered. + */ + @CheckResult + public fun markMessagesAsDelivered(messages: List): Call { + return api.markDelivered(messages) + .doOnStart(userScope) { + logger.d { "[markMessagesAsDelivered] #doOnStart; messages: ${messages.size}" } + } + .doOnResult(userScope) { result -> + logger.v { "[markMessagesAsDelivered] #doOnResult; completed: $result" } + } + } + /** * Marks a given thread as read. * diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt index d2d7469592d..5587e9db2c5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api/ChatApi.kt @@ -338,6 +338,11 @@ internal interface ChatApi { messageId: String = "", ): Call + @CheckResult + fun markDelivered( + messages: List, + ): Call + @CheckResult fun markThreadRead( channelType: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt index 42f3fe1f161..0cfd094e889 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/MoshiChatApi.kt @@ -57,6 +57,7 @@ import io.getstream.chat.android.client.api2.model.requests.FlagUserRequest import io.getstream.chat.android.client.api2.model.requests.GuestUserRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.InviteMembersRequest +import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest import io.getstream.chat.android.client.api2.model.requests.MarkReadRequest import io.getstream.chat.android.client.api2.model.requests.MarkUnreadRequest import io.getstream.chat.android.client.api2.model.requests.MuteChannelRequest @@ -960,6 +961,11 @@ constructor( ).toUnitCall() } + override fun markDelivered(messages: List): Call = + channelApi.markDelivered( + request = MarkDeliveredRequest.create(messages), + ).toUnitCall() + override fun markThreadRead(channelType: String, channelId: String, threadId: String): Call { return channelApi.markRead( channelType = channelType, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt index 378b1c7a08a..de5df53fe44 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/endpoint/ChannelApi.kt @@ -23,6 +23,7 @@ import io.getstream.chat.android.client.api2.model.requests.AcceptInviteRequest import io.getstream.chat.android.client.api2.model.requests.AddMembersRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest import io.getstream.chat.android.client.api2.model.requests.InviteMembersRequest +import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest import io.getstream.chat.android.client.api2.model.requests.MarkReadRequest import io.getstream.chat.android.client.api2.model.requests.MarkUnreadRequest import io.getstream.chat.android.client.api2.model.requests.PinnedMessagesRequest @@ -211,4 +212,9 @@ internal interface ChannelApi { @Path("id") channelId: String, @UrlQueryPayload @Query("payload") payload: PinnedMessagesRequest, ): RetrofitCall + + @POST("/channels/delivered") + fun markDelivered( + @Body request: MarkDeliveredRequest, + ): RetrofitCall } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkDeliveredRequest.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkDeliveredRequest.kt new file mode 100644 index 00000000000..5c4e2512d11 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/requests/MarkDeliveredRequest.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2014-2025 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.api2.model.requests + +import com.squareup.moshi.JsonClass +import io.getstream.chat.android.models.Message + +@JsonClass(generateAdapter = true) +internal data class MarkDeliveredRequest( + val latest_delivered_messages: List, +) { + companion object { + fun create(messages: List) = MarkDeliveredRequest( + latest_delivered_messages = messages.map { info -> + DeliveredMessageDto( + cid = info.cid, + id = info.id, + ) + }, + ) + } +} + +@JsonClass(generateAdapter = true) +internal data class DeliveredMessageDto( + val cid: String, + val id: String, +) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt index 7bf5589aeb5..1969636b507 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt @@ -44,6 +44,7 @@ import io.getstream.chat.android.randomExtraData import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomMessageList import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser import io.getstream.result.Error @@ -987,6 +988,33 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { verifyNetworkError(result, errorCode) } + @Test + fun markMessagesAsDeliveredSuccess() = runTest { + // given + val messages = randomMessageList(10) + val sut = Fixture() + .givenMarkDeliveredResult(RetroSuccess(Unit).toRetrofitCall()) + .get() + // when + val result = sut.markMessagesAsDelivered(messages).await() + // then + verifySuccess(result, Unit) + } + + @Test + fun markMessagesAsDeliveredError() = runTest { + // given + val messages = randomMessageList(10) + val errorCode = positiveRandomInt() + val sut = Fixture() + .givenMarkDeliveredResult(RetroError(errorCode).toRetrofitCall()) + .get() + // when + val result = sut.markMessagesAsDelivered(messages).await() + // then + verifyNetworkError(result, errorCode) + } + @Test fun markReadSuccess() = runTest { // given @@ -1528,6 +1556,10 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { whenever(api.markRead(any(), any(), any())).thenReturn(result) } + fun givenMarkDeliveredResult(result: Call) = apply { + whenever(api.markDelivered(any())).thenReturn(result) + } + fun givenMarkUnreadResult(result: Call) = apply { whenever(api.markUnread(any(), any(), any())).thenReturn(result) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt index 71ab28daa76..a160af4f4f8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTest.kt @@ -46,10 +46,12 @@ import io.getstream.chat.android.client.api2.model.requests.AcceptInviteRequest import io.getstream.chat.android.client.api2.model.requests.AddDeviceRequest import io.getstream.chat.android.client.api2.model.requests.BanUserRequest import io.getstream.chat.android.client.api2.model.requests.BlockUserRequest +import io.getstream.chat.android.client.api2.model.requests.DeliveredMessageDto import io.getstream.chat.android.client.api2.model.requests.FlagMessageRequest import io.getstream.chat.android.client.api2.model.requests.FlagUserRequest import io.getstream.chat.android.client.api2.model.requests.GuestUserRequest import io.getstream.chat.android.client.api2.model.requests.HideChannelRequest +import io.getstream.chat.android.client.api2.model.requests.MarkDeliveredRequest import io.getstream.chat.android.client.api2.model.requests.MarkReadRequest import io.getstream.chat.android.client.api2.model.requests.MarkUnreadRequest import io.getstream.chat.android.client.api2.model.requests.MuteChannelRequest @@ -147,6 +149,7 @@ import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMemberData import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomMessageList import io.getstream.chat.android.randomPollConfig import io.getstream.chat.android.randomReaction import io.getstream.chat.android.randomString @@ -1389,6 +1392,31 @@ internal class MoshiChatApiTest { verify(api, times(1)).markRead(channelType, channelId, expectedRequest) } + @ParameterizedTest + @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#markDeliveredInput") + fun testMarkDelivered(call: RetrofitCall, expected: KClass<*>) = runTest { + // given + val api = mock() + whenever(api.markDelivered(any())).doReturn(call) + val sut = Fixture() + .withChannelApi(api) + .get() + // when + val messages = randomMessageList(10) + val result = sut.markDelivered(messages).await() + // then + val expectedRequest = MarkDeliveredRequest( + latest_delivered_messages = messages.map { messageInfo -> + DeliveredMessageDto( + cid = messageInfo.cid, + id = messageInfo.id, + ) + }, + ) + result `should be instance of` expected + verify(api, times(1)).markDelivered(expectedRequest) + } + @ParameterizedTest @MethodSource("io.getstream.chat.android.client.api2.MoshiChatApiTestArguments#markThreadReadInput") fun testMarkThreadRead(call: RetrofitCall, expected: KClass<*>) = runTest { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt index dfe14aaab4e..f528b392f14 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/MoshiChatApiTestArguments.kt @@ -304,6 +304,9 @@ internal object MoshiChatApiTestArguments { @JvmStatic fun markReadInput() = completableResponseArguments() + @JvmStatic + fun markDeliveredInput() = completableResponseArguments() + @JvmStatic fun markThreadReadInput() = completableResponseArguments() From 84b2421baa2c636e939dc76cf1170d8d6e56aa18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 23 Oct 2025 11:29:26 +0100 Subject: [PATCH 02/58] Add delivery receipts support to user privacy settings --- .../client/api2/mapping/DomainMapping.kt | 7 +++++ .../client/api2/model/dto/PrivacySettings.kt | 6 +++++ .../api/stream-chat-android-core.api | 26 +++++++++++++++---- .../getstream/chat/android/PrivacySettings.kt | 13 ++++++++++ .../io/getstream/chat/android/models/User.kt | 5 ++++ .../user/internal/PrivacySettingsEntity.kt | 6 +++++ .../user/internal/PrivacySettingsMapper.kt | 11 ++++++++ 7 files changed, 69 insertions(+), 5 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 8898ced4226..b2b5e8fcaef 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.api2.mapping +import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.ReadReceipts import io.getstream.chat.android.TypingIndicators @@ -675,6 +676,7 @@ internal class DomainMapping( */ internal fun PrivacySettingsDto.toDomain(): PrivacySettings = PrivacySettings( typingIndicators = typing_indicators?.toDomain(), + deliveryReceipts = delivery_receipts?.toDomain(), readReceipts = read_receipts?.toDomain(), ) @@ -685,6 +687,11 @@ internal class DomainMapping( enabled = enabled, ) + /** + * Transforms [DeliveryReceiptsDto] to [DeliveryReceipts]. + */ + internal fun DeliveryReceiptsDto.toDomain() = DeliveryReceipts(enabled = enabled) + /** * Transforms [ReadReceiptsDto] to [ReadReceipts]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/PrivacySettings.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/PrivacySettings.kt index 9ea1a31ad94..31b7b74da12 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/PrivacySettings.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/PrivacySettings.kt @@ -21,6 +21,7 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) internal data class PrivacySettingsDto( val typing_indicators: TypingIndicatorsDto? = null, + val delivery_receipts: DeliveryReceiptsDto? = null, val read_receipts: ReadReceiptsDto? = null, ) @@ -29,6 +30,11 @@ internal data class TypingIndicatorsDto( val enabled: Boolean, ) +@JsonClass(generateAdapter = true) +internal data class DeliveryReceiptsDto( + val enabled: Boolean, +) + @JsonClass(generateAdapter = true) internal data class ReadReceiptsDto( val enabled: Boolean, diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 0a309e3930c..02aaec1b8bf 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1,12 +1,27 @@ +public final class io/getstream/chat/android/DeliveryReceipts { + public fun ()V + public fun (Z)V + public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun copy (Z)Lio/getstream/chat/android/DeliveryReceipts; + public static synthetic fun copy$default (Lio/getstream/chat/android/DeliveryReceipts;ZILjava/lang/Object;)Lio/getstream/chat/android/DeliveryReceipts; + public fun equals (Ljava/lang/Object;)Z + public final fun getEnabled ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/PrivacySettings { public fun ()V - public fun (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/ReadReceipts;)V - public synthetic fun (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/ReadReceipts;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/DeliveryReceipts;Lio/getstream/chat/android/ReadReceipts;)V + public synthetic fun (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/DeliveryReceipts;Lio/getstream/chat/android/ReadReceipts;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/TypingIndicators; - public final fun component2 ()Lio/getstream/chat/android/ReadReceipts; - public final fun copy (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/ReadReceipts;)Lio/getstream/chat/android/PrivacySettings; - public static synthetic fun copy$default (Lio/getstream/chat/android/PrivacySettings;Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/ReadReceipts;ILjava/lang/Object;)Lio/getstream/chat/android/PrivacySettings; + public final fun component2 ()Lio/getstream/chat/android/DeliveryReceipts; + public final fun component3 ()Lio/getstream/chat/android/ReadReceipts; + public final fun copy (Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/DeliveryReceipts;Lio/getstream/chat/android/ReadReceipts;)Lio/getstream/chat/android/PrivacySettings; + public static synthetic fun copy$default (Lio/getstream/chat/android/PrivacySettings;Lio/getstream/chat/android/TypingIndicators;Lio/getstream/chat/android/DeliveryReceipts;Lio/getstream/chat/android/ReadReceipts;ILjava/lang/Object;)Lio/getstream/chat/android/PrivacySettings; public fun equals (Ljava/lang/Object;)Z + public final fun getDeliveryReceipts ()Lio/getstream/chat/android/DeliveryReceipts; public final fun getReadReceipts ()Lio/getstream/chat/android/ReadReceipts; public final fun getTypingIndicators ()Lio/getstream/chat/android/TypingIndicators; public fun hashCode ()I @@ -2346,6 +2361,7 @@ public final class io/getstream/chat/android/models/User : io/getstream/chat/and public final fun getUpdatedAt ()Ljava/util/Date; public fun hashCode ()I public final fun isBanned ()Z + public final fun isDeliveryReceiptsEnabled ()Z public final fun isInvisible ()Z public final fun isReadReceiptsEnabled ()Z public final fun isTypingIndicatorsEnabled ()Z diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt index e881069aaff..a12752441d3 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/PrivacySettings.kt @@ -22,11 +22,13 @@ import androidx.compose.runtime.Immutable * Represents the privacy settings of a user. * * @param typingIndicators Typing indicators settings. + * @param deliveryReceipts Delivery receipts settings. * @param readReceipts Read receipts settings. */ @Immutable public data class PrivacySettings( public val typingIndicators: TypingIndicators? = null, + public val deliveryReceipts: DeliveryReceipts? = null, public val readReceipts: ReadReceipts? = null, ) @@ -41,6 +43,17 @@ public data class TypingIndicators( val enabled: Boolean = true, ) +/** + * Represents the delivery receipts settings. + * If false, the user delivery events will not be sent to other users, along with the user's delivery state. + * + * @param enabled Whether delivery receipts are enabled or not. + */ +@Immutable +public data class DeliveryReceipts( + val enabled: Boolean = true, +) + /** * Represents the read receipts settings. * If false, the user read events will not be sent to other users, along with the user's read state. diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt index 12063a84f2f..704e2005675 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/User.kt @@ -99,6 +99,11 @@ public data class User( */ val isReadReceiptsEnabled: Boolean get() = privacySettings?.readReceipts?.enabled ?: true + /** + * Determines if the user has delivery receipts enabled. + */ + val isDeliveryReceiptsEnabled: Boolean get() = privacySettings?.deliveryReceipts?.enabled ?: true + override fun getComparableField(fieldName: String): Comparable<*>? { return when (fieldName) { "id" -> id diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsEntity.kt index 50df597dace..ae6e4e343a3 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsEntity.kt @@ -22,6 +22,7 @@ import com.squareup.moshi.JsonClass internal data class PrivacySettingsEntity( val typingIndicators: TypingIndicatorsEntity? = null, val readReceipts: ReadReceiptsEntity? = null, + val deliveryReceipts: DeliveryReceiptsEntity? = null, ) @JsonClass(generateAdapter = true) @@ -33,3 +34,8 @@ internal data class TypingIndicatorsEntity( internal data class ReadReceiptsEntity( val enabled: Boolean, ) + +@JsonClass(generateAdapter = true) +internal data class DeliveryReceiptsEntity( + val enabled: Boolean, +) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt index 778ed4e63bc..14ea36865af 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.offline.repository.domain.user.internal +import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.ReadReceipts import io.getstream.chat.android.TypingIndicators @@ -32,6 +33,11 @@ internal fun PrivacySettings.toEntity(): PrivacySettingsEntity { enabled = it.enabled, ) }, + deliveryReceipts = deliveryReceipts?.let { it -> + DeliveryReceiptsEntity( + enabled = it.enabled, + ) + }, ) } @@ -47,5 +53,10 @@ internal fun PrivacySettingsEntity.toModel(): PrivacySettings { enabled = it.enabled, ) }, + deliveryReceipts = deliveryReceipts?.let { + DeliveryReceipts( + enabled = it.enabled, + ) + }, ) } From 3fc24af1c132bab9d0c1395c4e488ebfa14fe3e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 23 Oct 2025 12:08:14 +0100 Subject: [PATCH 03/58] Increase max return count in detekt configuration --- config/detekt/detekt.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index 33c0dd9d608..0a67d2e8c85 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -731,8 +731,8 @@ style: active: false ReturnCount: active: true - max: 2 - excludedFunctions: 'equals' + max: 10 + excludedFunctions: ['equals'] excludeLabeled: false excludeReturnFromLambda: true excludeGuardClauses: false From 81fa4a7f337f5c230723d197c861f590176bb822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 23 Oct 2025 12:09:50 +0100 Subject: [PATCH 04/58] Introduce a initial DeliveryReceiptsManager to handle message delivery receipts --- .../client/api2/mapping/DomainMapping.kt | 1 + .../receipts/DeliveryReceiptsManager.kt | 88 +++++++++ .../receipts/DeliveryReceiptsManagerTest.kt | 168 ++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManager.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManagerTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index b2b5e8fcaef..49598a56058 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.client.api2.model.dto.AttachmentDto import io.getstream.chat.android.client.api2.model.dto.ChannelInfoDto import io.getstream.chat.android.client.api2.model.dto.CommandDto import io.getstream.chat.android.client.api2.model.dto.ConfigDto +import io.getstream.chat.android.client.api2.model.dto.DeliveryReceiptsDto import io.getstream.chat.android.client.api2.model.dto.DeviceDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelDto import io.getstream.chat.android.client.api2.model.dto.DownstreamChannelMuteDto diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManager.kt new file mode 100644 index 00000000000..6d27568e75d --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManager.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2014-2025 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.receipts + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.utils.message.isDeleted +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageType +import io.getstream.chat.android.models.User +import io.getstream.chat.android.models.UserId +import io.getstream.log.taggedLogger + +internal class DeliveryReceiptsManager( + private val chatClient: ChatClient, + private val getCurrentUser: () -> User?, +) { + + private val logger by taggedLogger("MessageDeliveryReceiptsManager") + + fun markMessagesAsDelivered(messages: List) { + logger.d { "[markMessagesAsDelivered] Preparing to send delivery receipts for ${messages.size} messages" } + + val currentUser = requireNotNull(getCurrentUser()) { + "Cannot send delivery receipts: current user is null" + } + + // Check if delivery receipts are enabled for the current user + if (!currentUser.isDeliveryReceiptsEnabled()) { + logger.w { "[markMessagesAsDelivered] Delivery receipts disabled for user ${currentUser.id}" } + return + } + + val filteredMessages = messages.filter { message -> + shouldSendDeliveryReceipt(currentUserId = currentUser.id, message = message) + } + if (filteredMessages.size != messages.size) { + logger.d { + "[markMessagesAsDelivered] " + + "Skipping delivery receipts for ${messages.size - filteredMessages.size} messages" + } + } + + if (filteredMessages.isEmpty()) { + logger.w { "[markMessagesAsDelivered] No receipts to send" } + return + } + + logger.d { "[markMessagesAsDelivered] Sending ${filteredMessages.size} delivery receipts" } + chatClient.markMessagesAsDelivered(filteredMessages) + .execute() + } + + private fun shouldSendDeliveryReceipt(currentUserId: UserId, message: Message): Boolean { + // Don't send delivery receipts for messages sent by the current user + if (message.user.id == currentUserId) { + return false + } + + // Don't send delivery receipts for system messages + if (message.type == MessageType.SYSTEM) { + return false + } + + // Don't send delivery receipts for deleted messages + if (message.isDeleted()) { + return false + } + + return true + } +} + +private fun User.isDeliveryReceiptsEnabled(): Boolean = + privacySettings?.deliveryReceipts?.enabled ?: false diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManagerTest.kt new file mode 100644 index 00000000000..6a2381a6286 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManagerTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2014-2025 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.receipts + +import io.getstream.chat.android.DeliveryReceipts +import io.getstream.chat.android.PrivacySettings +import io.getstream.chat.android.client.ChatClient +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.randomMessageList +import io.getstream.chat.android.randomUser +import io.getstream.chat.android.test.asCall +import org.junit.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import java.util.Date + +internal class DeliveryReceiptsManagerTest { + + @Test + fun `mark messages as delivered`() { + val deliveredMessage = randomMessage() + val messages = listOf( + deliveredMessage, + randomMessage(user = CurrentUser), + randomMessage(type = "system"), + randomMessage(deletedAt = Date()), + ) + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessagesAsDelivered(messages) + + fixture.verifyMarkMessagesAsDelivered(listOf(deliveredMessage)) + } + + @Test + fun `should not mark messages as delivered when current user is null`() { + val messages = randomMessageList(10) { randomMessage() } + val fixture = Fixture().givenCurrentUser(user = null) + val sut = fixture.get() + + assertThrows(message = "Cannot send delivery receipts: current user is null") { + sut.markMessagesAsDelivered(messages) + } + } + + @Test + fun `should skip mark messages as delivered when current user privacy settings are undefined`() { + val currentUser = randomUser(privacySettings = null) + val messages = randomMessageList(10) { randomMessage() } + val fixture = Fixture().givenCurrentUser(currentUser) + val sut = fixture.get() + + sut.markMessagesAsDelivered(messages) + + fixture.verifyNoInteractions() + } + + @Test + fun `should skip mark messages as delivered when delivery receipts are disabled`() { + val currentUser = randomUser( + privacySettings = PrivacySettings( + deliveryReceipts = DeliveryReceipts(enabled = false), + ), + ) + val messages = randomMessageList(10) { randomMessage() } + val fixture = Fixture().givenCurrentUser(currentUser) + val sut = fixture.get() + + sut.markMessagesAsDelivered(messages) + + fixture.verifyNoInteractions() + } + + @Test + fun `should skip mark messages as delivered with empty list`() { + val messages = emptyList() + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessagesAsDelivered(messages) + + fixture.verifyNoInteractions() + } + + @Test + fun `should skip mark messages from the current user as delivered`() { + val messages = randomMessageList(10) { randomMessage(user = CurrentUser) } + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessagesAsDelivered(messages) + + fixture.verifyNoInteractions() + } + + @Test + fun `should skip mark system messages as delivered`() { + val messages = randomMessageList(10) { randomMessage(type = "system") } + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessagesAsDelivered(messages) + + fixture.verifyNoInteractions() + } + + @Test + fun `should skip mark deleted messages as delivered`() { + val messages = randomMessageList(10) { randomMessage(deletedAt = Date()) } + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessagesAsDelivered(messages) + + fixture.verifyNoInteractions() + } + + private class Fixture { + private val mockChatClient = mock { + on { markMessagesAsDelivered(any()) } doReturn Unit.asCall() + } + private var getCurrentUser: () -> User? = { CurrentUser } + + fun givenCurrentUser(user: User?) = apply { + getCurrentUser = { user } + } + + fun verifyNoInteractions() { + verifyNoInteractions(mockChatClient) + } + + fun verifyMarkMessagesAsDelivered(messages: List) { + verify(mockChatClient).markMessagesAsDelivered(messages) + } + + fun get() = DeliveryReceiptsManager( + chatClient = mockChatClient, + getCurrentUser = getCurrentUser, + ) + } +} + +private val CurrentUser = randomUser( + privacySettings = PrivacySettings( + deliveryReceipts = DeliveryReceipts(enabled = true), + ), +) From 17e3b5d994deaa8152947498ceaa2d2913480dfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 23 Oct 2025 14:07:50 +0100 Subject: [PATCH 05/58] Introduce MessageReceiptDao and MessageReceiptEntity for handling message receipts --- .../api/stream-chat-android-offline.api | 9 +++++++++ .../database/internal/ChatDatabase.kt | 6 +++++- .../domain/receipts/MessageReceiptDao.kt | 19 +++++++++++++++++++ .../domain/receipts/MessageReceiptEntity.kt | 14 ++++++++++++++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt create mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt diff --git a/stream-chat-android-offline/api/stream-chat-android-offline.api b/stream-chat-android-offline/api/stream-chat-android-offline.api index f9c6222b504..8e6cf09aa81 100644 --- a/stream-chat-android-offline/api/stream-chat-android-offline.api +++ b/stream-chat-android-offline/api/stream-chat-android-offline.api @@ -22,6 +22,7 @@ public final class io/getstream/chat/android/offline/repository/database/interna public fun getAutoMigrations (Ljava/util/Map;)Ljava/util/List; public fun getRequiredAutoMigrationSpecs ()Ljava/util/Set; public fun messageDao ()Lio/getstream/chat/android/offline/repository/domain/message/internal/MessageDao; + public fun messageReceiptDao ()Lio/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao; public fun pollDao ()Lio/getstream/chat/android/offline/repository/domain/message/internal/PollDao; public fun queryChannelsDao ()Lio/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsDao; public fun reactionDao ()Lio/getstream/chat/android/offline/repository/domain/reaction/internal/ReactionDao; @@ -187,6 +188,14 @@ public final class io/getstream/chat/android/offline/repository/domain/reaction/ public fun setDeleteAt (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao_Impl : io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao { + public fun (Landroidx/room/RoomDatabase;)V + public fun deleteByMessageIds (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun getRequiredConverters ()Ljava/util/List; + public fun selectAllByType (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class io/getstream/chat/android/offline/repository/domain/syncState/internal/SyncStateDao_Impl : io/getstream/chat/android/offline/repository/domain/syncState/internal/SyncStateDao { public fun (Landroidx/room/RoomDatabase;)V public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt index ba6e9104dfb..fff73808bf3 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt @@ -60,6 +60,8 @@ import io.getstream.chat.android.offline.repository.domain.queryChannels.interna import io.getstream.chat.android.offline.repository.domain.queryChannels.internal.QueryChannelsEntity import io.getstream.chat.android.offline.repository.domain.reaction.internal.ReactionDao import io.getstream.chat.android.offline.repository.domain.reaction.internal.ReactionEntity +import io.getstream.chat.android.offline.repository.domain.receipts.MessageReceiptDao +import io.getstream.chat.android.offline.repository.domain.receipts.MessageReceiptEntity import io.getstream.chat.android.offline.repository.domain.syncState.internal.SyncStateDao import io.getstream.chat.android.offline.repository.domain.syncState.internal.SyncStateEntity import io.getstream.chat.android.offline.repository.domain.threads.internal.ThreadDao @@ -86,8 +88,9 @@ import io.getstream.chat.android.offline.repository.domain.user.internal.UserEnt ThreadEntity::class, ThreadOrderEntity::class, DraftMessageEntity::class, + MessageReceiptEntity::class, ], - version = 95, + version = 96, exportSchema = false, ) @TypeConverters( @@ -124,6 +127,7 @@ internal abstract class ChatDatabase : RoomDatabase() { abstract fun pollDao(): PollDao abstract fun threadDao(): ThreadDao abstract fun threadOrderDao(): ThreadOrderDao + abstract fun messageReceiptDao(): MessageReceiptDao companion object { @Volatile diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt new file mode 100644 index 00000000000..6eab0fe589f --- /dev/null +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt @@ -0,0 +1,19 @@ +package io.getstream.chat.android.offline.repository.domain.receipts + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +internal interface MessageReceiptDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(receipts: List) + + @Query("SELECT * FROM stream_chat_message_receipt WHERE receiptType = :type ORDER BY createdAt ASC") + suspend fun selectAllByType(type: String): List + + @Query("DELETE FROM stream_chat_message_receipt WHERE messageId IN (:messageIds)") + suspend fun deleteByMessageIds(messageIds: List) +} diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt new file mode 100644 index 00000000000..969dc589a90 --- /dev/null +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt @@ -0,0 +1,14 @@ +package io.getstream.chat.android.offline.repository.domain.receipts + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.Date + +@Entity(tableName = "stream_chat_message_receipt") +internal data class MessageReceiptEntity( + @PrimaryKey + val messageId: String, + val receiptType: String, + val createdAt: Date, + val cid: String, +) From 52d7305fda5d3a1c55f64d425585662663135be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 23 Oct 2025 15:20:10 +0100 Subject: [PATCH 06/58] Introduce MessageReceipt model and repository for handling message delivery receipts --- .../api/stream-chat-android-core.api | 22 +++++ .../chat/android/models/MessageReceipt.kt | 32 +++++++ .../io/getstream/chat/android/Mother.kt | 13 +++ .../domain/receipts/MessageReceiptDao.kt | 2 +- .../domain/receipts/MessageReceiptEntity.kt | 2 +- .../domain/receipts/MessageReceiptMapper.kt | 17 ++++ .../receipts/MessageReceiptRepositoryImpl.kt | 20 ++++ .../getstream/chat/android/offline/Mother.kt | 13 +++ .../MessageReceiptRepositoryImplTest.kt | 92 +++++++++++++++++++ 9 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MessageReceipt.kt create mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptMapper.kt create mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt create mode 100644 stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 02aaec1b8bf..7df233a302f 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1439,6 +1439,28 @@ public final class io/getstream/chat/android/models/MessageModerationDetails { public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/models/MessageReceipt { + public static final field Companion Lio/getstream/chat/android/models/MessageReceipt$Companion; + public static final field TYPE_DELIVERED Ljava/lang/String; + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/util/Date; + public final fun component4 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/models/MessageReceipt; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/MessageReceipt;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/MessageReceipt; + public fun equals (Ljava/lang/Object;)Z + public final fun getCid ()Ljava/lang/String; + public final fun getCreatedAt ()Ljava/util/Date; + public final fun getMessageId ()Ljava/lang/String; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class io/getstream/chat/android/models/MessageReceipt$Companion { +} + public final class io/getstream/chat/android/models/MessageReminder : io/getstream/chat/android/models/querysort/ComparableFieldProvider { public fun (Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Ljava/util/Date;Ljava/util/Date;)V public final fun component1 ()Ljava/util/Date; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MessageReceipt.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MessageReceipt.kt new file mode 100644 index 00000000000..fe75dbb2131 --- /dev/null +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MessageReceipt.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2014-2025 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.models + +import androidx.compose.runtime.Immutable +import java.util.Date + +@Immutable +public data class MessageReceipt( + public val messageId: String, + public val type: String, + public val createdAt: Date, + public val cid: String, +) { + public companion object { + public const val TYPE_DELIVERED: String = "delivered" + } +} diff --git a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt index 65c607760f7..e6035bd6a8b 100644 --- a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt +++ b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt @@ -41,6 +41,7 @@ import io.getstream.chat.android.models.MemberData import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageModerationAction import io.getstream.chat.android.models.MessageModerationDetails +import io.getstream.chat.android.models.MessageReceipt import io.getstream.chat.android.models.MessageReminder import io.getstream.chat.android.models.MessageReminderInfo import io.getstream.chat.android.models.Moderation @@ -1149,3 +1150,15 @@ public fun attachmentTypes(): List = listOf( AttachmentType.AUDIO_RECORDING, AttachmentType.UNKNOWN, ) + +public fun randomMessageReceipt( + messageId: String = randomString(), + type: String = randomString(), + createdAt: Date = randomDate(), + cid: String = randomCID(), +): MessageReceipt = MessageReceipt( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt index 6eab0fe589f..6960c3f9020 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt @@ -11,7 +11,7 @@ internal interface MessageReceiptDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(receipts: List) - @Query("SELECT * FROM stream_chat_message_receipt WHERE receiptType = :type ORDER BY createdAt ASC") + @Query("SELECT * FROM stream_chat_message_receipt WHERE type = :type ORDER BY createdAt ASC") suspend fun selectAllByType(type: String): List @Query("DELETE FROM stream_chat_message_receipt WHERE messageId IN (:messageIds)") diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt index 969dc589a90..b885da6501a 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt @@ -8,7 +8,7 @@ import java.util.Date internal data class MessageReceiptEntity( @PrimaryKey val messageId: String, - val receiptType: String, + val type: String, val createdAt: Date, val cid: String, ) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptMapper.kt new file mode 100644 index 00000000000..b1e1e6228f6 --- /dev/null +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptMapper.kt @@ -0,0 +1,17 @@ +package io.getstream.chat.android.offline.repository.domain.receipts + +import io.getstream.chat.android.models.MessageReceipt + +internal fun MessageReceipt.toEntity() = MessageReceiptEntity( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) + +internal fun MessageReceiptEntity.toModel() = MessageReceipt( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt new file mode 100644 index 00000000000..ed807c90b0f --- /dev/null +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt @@ -0,0 +1,20 @@ +package io.getstream.chat.android.offline.repository.domain.receipts + +import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository +import io.getstream.chat.android.models.MessageReceipt + +internal class MessageReceiptRepositoryImpl( + private val dao: MessageReceiptDao, +) : MessageReceiptRepository { + + override suspend fun upsert(receipts: List) { + dao.upsert(receipts.map(MessageReceipt::toEntity)) + } + + override suspend fun getAllByType(type: String): List = + dao.selectAllByType(type).map(MessageReceiptEntity::toModel) + + override suspend fun deleteByMessageIds(messageIds: List) { + dao.deleteByMessageIds(messageIds) + } +} diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt index 88040f0624d..4d0c71907bc 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt @@ -34,6 +34,7 @@ import io.getstream.chat.android.offline.repository.domain.message.internal.Reac import io.getstream.chat.android.offline.repository.domain.message.internal.ReminderInfoEntity import io.getstream.chat.android.offline.repository.domain.queryChannels.internal.QueryChannelsEntity import io.getstream.chat.android.offline.repository.domain.reaction.internal.ReactionEntity +import io.getstream.chat.android.offline.repository.domain.receipts.MessageReceiptEntity import io.getstream.chat.android.offline.repository.domain.threads.internal.ThreadEntity import io.getstream.chat.android.offline.repository.domain.user.internal.PrivacySettingsEntity import io.getstream.chat.android.offline.repository.domain.user.internal.UserEntity @@ -273,3 +274,15 @@ internal fun randomThreadEntity( latestReplyIds = latestReplyIds, extraData = extraData, ) + +internal fun randomMessageReceiptEntity( + messageId: String = randomString(), + type: String = randomString(), + createdAt: Date = randomDate(), + cid: String = randomCID(), +) = MessageReceiptEntity( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt new file mode 100644 index 00000000000..9ef3b2653c8 --- /dev/null +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt @@ -0,0 +1,92 @@ +package io.getstream.chat.android.offline.repository.domain.receipts + +import io.getstream.chat.android.models.MessageReceipt +import io.getstream.chat.android.offline.randomMessageReceiptEntity +import io.getstream.chat.android.randomMessageReceipt +import io.getstream.chat.android.randomString +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking + +internal class MessageReceiptRepositoryImplTest { + + @Test + fun `upsert receipts`() = runTest { + val receipt = randomMessageReceipt() + val fixture = Fixture() + val sut = fixture.get() + + sut.upsert(receipts = listOf(receipt)) + + val expectedReceipts = listOf( + MessageReceiptEntity( + messageId = receipt.messageId, + type = receipt.type, + createdAt = receipt.createdAt, + cid = receipt.cid, + ) + ) + fixture.verifyUpsert(expectedReceipts) + } + + @Test + fun `get receipts by type`() = runTest { + val type = randomString() + val receipt = randomMessageReceiptEntity() + val fixture = Fixture() + .givenReceiptsByType( + type = type, + receipts = listOf(receipt), + ) + val sut = fixture.get() + + val actual = sut.getAllByType(type) + + val expected = listOf( + MessageReceipt( + messageId = receipt.messageId, + type = receipt.type, + createdAt = receipt.createdAt, + cid = receipt.cid, + ) + ) + assertEquals(expected, actual) + } + + @Test + fun `delete receipts by message IDs`() = runTest { + val messageIds = listOf(randomString()) + val fixture = Fixture() + val sut = fixture.get() + + sut.deleteByMessageIds(messageIds) + + fixture.verifyDeleteByMessageIds(messageIds) + } + + private class Fixture { + private val mockDao = mock { + onBlocking { upsert(any()) } doReturn Unit + onBlocking { deleteByMessageIds(any()) } doReturn Unit + } + + fun givenReceiptsByType(type: String, receipts: List) = apply { + wheneverBlocking { mockDao.selectAllByType(type) } doReturn receipts + } + + fun verifyUpsert(receipts: List) { + verifyBlocking(mockDao) { upsert(receipts) } + } + + fun verifyDeleteByMessageIds(messageIds: List) { + verifyBlocking(mockDao) { deleteByMessageIds(messageIds) } + } + + fun get() = MessageReceiptRepositoryImpl(dao = mockDao) + } +} From b4b6e19d25da1bbbc8e0577f867f778271d6f202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 24 Oct 2025 13:42:57 +0100 Subject: [PATCH 07/58] Make MessageReceiptRepository `getAllByType` return a Flow and add a `clear` function The `MessageReceiptRepository.getAllByType` function now accepts a `limit` parameter and returns a `Flow>` instead of a `suspend` function returning `List`. A new `clear()` function has been added to `MessageReceiptRepository` to delete all receipts from the database. --- .../repository/MessageReceiptRepository.kt | 34 ++++++++++++ .../api/stream-chat-android-offline.api | 3 +- .../domain/receipts/MessageReceiptDao.kt | 13 ++++- .../receipts/MessageReceiptRepositoryImpl.kt | 13 ++++- .../MessageReceiptRepositoryImplTest.kt | 55 ++++++++++++++----- 5 files changed, 98 insertions(+), 20 deletions(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageReceiptRepository.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageReceiptRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageReceiptRepository.kt new file mode 100644 index 00000000000..8d443be3ea4 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageReceiptRepository.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2014-2025 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.persistance.repository + +import io.getstream.chat.android.core.internal.InternalStreamChatApi +import io.getstream.chat.android.models.MessageReceipt +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +@InternalStreamChatApi +public interface MessageReceiptRepository { + + public suspend fun upsert(receipts: List) { /* no-op */ } + + public fun getAllByType(type: String, limit: Int): Flow> = emptyFlow() + + public suspend fun deleteByMessageIds(messageIds: List) { /* no-op */ } + + public suspend fun clear() { /* no-op */ } +} diff --git a/stream-chat-android-offline/api/stream-chat-android-offline.api b/stream-chat-android-offline/api/stream-chat-android-offline.api index 8e6cf09aa81..8b0c120ab9c 100644 --- a/stream-chat-android-offline/api/stream-chat-android-offline.api +++ b/stream-chat-android-offline/api/stream-chat-android-offline.api @@ -190,9 +190,10 @@ public final class io/getstream/chat/android/offline/repository/domain/reaction/ public final class io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao_Impl : io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao { public fun (Landroidx/room/RoomDatabase;)V + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun deleteByMessageIds (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun getRequiredConverters ()Ljava/util/List; - public fun selectAllByType (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun selectAllByType (Ljava/lang/String;I)Lkotlinx/coroutines/flow/Flow; public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt index 6960c3f9020..3f7c8f022cc 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt @@ -4,6 +4,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow @Dao internal interface MessageReceiptDao { @@ -11,9 +12,17 @@ internal interface MessageReceiptDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(receipts: List) - @Query("SELECT * FROM stream_chat_message_receipt WHERE type = :type ORDER BY createdAt ASC") - suspend fun selectAllByType(type: String): List + @Query( + "SELECT * FROM stream_chat_message_receipt " + + "WHERE type = :type " + + "ORDER BY createdAt ASC " + + "LIMIT :limit" + ) + fun selectAllByType(type: String, limit: Int): Flow> @Query("DELETE FROM stream_chat_message_receipt WHERE messageId IN (:messageIds)") suspend fun deleteByMessageIds(messageIds: List) + + @Query("DELETE FROM stream_chat_message_receipt") + suspend fun deleteAll() } diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt index ed807c90b0f..b7e50f63d63 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt @@ -2,6 +2,8 @@ package io.getstream.chat.android.offline.repository.domain.receipts import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository import io.getstream.chat.android.models.MessageReceipt +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map internal class MessageReceiptRepositoryImpl( private val dao: MessageReceiptDao, @@ -11,10 +13,17 @@ internal class MessageReceiptRepositoryImpl( dao.upsert(receipts.map(MessageReceipt::toEntity)) } - override suspend fun getAllByType(type: String): List = - dao.selectAllByType(type).map(MessageReceiptEntity::toModel) + override fun getAllByType(type: String, limit: Int): Flow> = + dao.selectAllByType(type, limit) + .map { receipts -> + receipts.map(MessageReceiptEntity::toModel) + } override suspend fun deleteByMessageIds(messageIds: List) { dao.deleteByMessageIds(messageIds) } + + override suspend fun clear() { + dao.deleteAll() + } } diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt index 9ef3b2653c8..e7e8fef730a 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt @@ -1,9 +1,12 @@ package io.getstream.chat.android.offline.repository.domain.receipts +import app.cash.turbine.test import io.getstream.chat.android.models.MessageReceipt import io.getstream.chat.android.offline.randomMessageReceiptEntity +import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomMessageReceipt import io.getstream.chat.android.randomString +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals @@ -31,31 +34,35 @@ internal class MessageReceiptRepositoryImplTest { cid = receipt.cid, ) ) - fixture.verifyUpsert(expectedReceipts) + fixture.verifyUpsertCalled(expectedReceipts) } @Test fun `get receipts by type`() = runTest { val type = randomString() + val limit = randomInt() val receipt = randomMessageReceiptEntity() val fixture = Fixture() .givenReceiptsByType( type = type, + limit = limit, receipts = listOf(receipt), ) val sut = fixture.get() - val actual = sut.getAllByType(type) + sut.getAllByType(type, limit).test { + val actual = awaitItem() - val expected = listOf( - MessageReceipt( - messageId = receipt.messageId, - type = receipt.type, - createdAt = receipt.createdAt, - cid = receipt.cid, + val expected = listOf( + MessageReceipt( + messageId = receipt.messageId, + type = receipt.type, + createdAt = receipt.createdAt, + cid = receipt.cid, + ) ) - ) - assertEquals(expected, actual) + assertEquals(expected, actual) + } } @Test @@ -66,27 +73,45 @@ internal class MessageReceiptRepositoryImplTest { sut.deleteByMessageIds(messageIds) - fixture.verifyDeleteByMessageIds(messageIds) + fixture.verifyDeleteByMessageIdsCalled(messageIds) + } + + @Test + fun `clear receipts`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.clear() + + fixture.verifyDeleteAllCalled() } private class Fixture { + + private val receiptsStateFlow = MutableStateFlow>(emptyList()) + private val mockDao = mock { onBlocking { upsert(any()) } doReturn Unit onBlocking { deleteByMessageIds(any()) } doReturn Unit } - fun givenReceiptsByType(type: String, receipts: List) = apply { - wheneverBlocking { mockDao.selectAllByType(type) } doReturn receipts + fun givenReceiptsByType(type: String, limit: Int, receipts: List) = apply { + wheneverBlocking { mockDao.selectAllByType(type, limit) } doReturn + receiptsStateFlow.apply { value = receipts } } - fun verifyUpsert(receipts: List) { + fun verifyUpsertCalled(receipts: List) { verifyBlocking(mockDao) { upsert(receipts) } } - fun verifyDeleteByMessageIds(messageIds: List) { + fun verifyDeleteByMessageIdsCalled(messageIds: List) { verifyBlocking(mockDao) { deleteByMessageIds(messageIds) } } + fun verifyDeleteAllCalled() { + verifyBlocking(mockDao) { deleteAll() } + } + fun get() = MessageReceiptRepositoryImpl(dao = mockDao) } } From ca176acb0ed95c3940c94002c1239af7bb194fa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 24 Oct 2025 13:45:38 +0100 Subject: [PATCH 08/58] Refactor DeliveryReceiptsManager to store receipts locally The `DeliveryReceiptsManager` has been renamed to `MessageReceiptManager`. Instead of making a direct API call to mark messages as delivered, the manager now creates `MessageReceipt` objects and upserts them into the local `MessageReceiptRepository`. This change also refactors the corresponding tests to verify the local database interaction rather than the API call. --- ...ptsManager.kt => MessageReceiptManager.kt} | 36 +++++++-- ...erTest.kt => MessageReceiptManagerTest.kt} | 77 +++++++++++-------- 2 files changed, 73 insertions(+), 40 deletions(-) rename stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/{DeliveryReceiptsManager.kt => MessageReceiptManager.kt} (68%) rename stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/{DeliveryReceiptsManagerTest.kt => MessageReceiptManagerTest.kt} (60%) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt similarity index 68% rename from stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManager.kt rename to stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index 6d27568e75d..831fef988f8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -16,23 +16,32 @@ package io.getstream.chat.android.client.receipts -import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageReceipt import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.User import io.getstream.chat.android.models.UserId import io.getstream.log.taggedLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.Date -internal class DeliveryReceiptsManager( - private val chatClient: ChatClient, +/** + * Manages message delivery receipts: creating and storing them in the repository. + */ +internal class MessageReceiptManager( + private val scope: CoroutineScope, + private val now: () -> Date, private val getCurrentUser: () -> User?, + private val messageReceiptRepository: MessageReceiptRepository, ) { - private val logger by taggedLogger("MessageDeliveryReceiptsManager") + private val logger by taggedLogger("MessageReceiptManager") fun markMessagesAsDelivered(messages: List) { - logger.d { "[markMessagesAsDelivered] Preparing to send delivery receipts for ${messages.size} messages" } + logger.d { "[markMessagesAsDelivered] Preparing delivery receipts for ${messages.size} messages…" } val currentUser = requireNotNull(getCurrentUser()) { "Cannot send delivery receipts: current user is null" @@ -44,6 +53,7 @@ internal class DeliveryReceiptsManager( return } + // Filter out messages that shouldn't have delivery receipts sent val filteredMessages = messages.filter { message -> shouldSendDeliveryReceipt(currentUserId = currentUser.id, message = message) } @@ -59,9 +69,12 @@ internal class DeliveryReceiptsManager( return } - logger.d { "[markMessagesAsDelivered] Sending ${filteredMessages.size} delivery receipts" } - chatClient.markMessagesAsDelivered(filteredMessages) - .execute() + scope.launch { + val receipts = filteredMessages.map { message -> message.toDeliveryReceipt() } + messageReceiptRepository.upsert(receipts) + + logger.d { "[markMessagesAsDelivered] ${filteredMessages.size} delivery receipts upserted" } + } } private fun shouldSendDeliveryReceipt(currentUserId: UserId, message: Message): Boolean { @@ -82,6 +95,13 @@ internal class DeliveryReceiptsManager( return true } + + private fun Message.toDeliveryReceipt() = MessageReceipt( + messageId = id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = now(), + cid = cid, + ) } private fun User.isDeliveryReceiptsEnabled(): Boolean = diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt similarity index 60% rename from stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManagerTest.kt rename to stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index 6a2381a6286..598efa365d4 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/DeliveryReceiptsManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -18,43 +18,54 @@ package io.getstream.chat.android.client.receipts import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings -import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageReceipt import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomDate import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomMessageList import io.getstream.chat.android.randomUser -import io.getstream.chat.android.test.asCall +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.jupiter.api.assertThrows import org.mockito.kotlin.any -import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.never +import org.mockito.kotlin.verifyBlocking import java.util.Date -internal class DeliveryReceiptsManagerTest { +internal class MessageReceiptManagerTest { @Test - fun `mark messages as delivered`() { - val deliveredMessage = randomMessage() + fun `store message delivery receipts success`() = runTest { + val deliveredMessage = randomMessage(deletedAt = null, deletedForMe = false) val messages = listOf( deliveredMessage, randomMessage(user = CurrentUser), randomMessage(type = "system"), - randomMessage(deletedAt = Date()), + randomMessage(deletedAt = randomDate()), ) val fixture = Fixture() val sut = fixture.get() sut.markMessagesAsDelivered(messages) - fixture.verifyMarkMessagesAsDelivered(listOf(deliveredMessage)) + val receipts = listOf( + MessageReceipt( + messageId = deliveredMessage.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = deliveredMessage.cid, + ), + ) + fixture.verifyUpsertMessageReceiptsCalled(receipts) } @Test - fun `should not mark messages as delivered when current user is null`() { + fun `should not store message delivery receipts when current user is null`() = runTest { val messages = randomMessageList(10) { randomMessage() } val fixture = Fixture().givenCurrentUser(user = null) val sut = fixture.get() @@ -65,7 +76,7 @@ internal class DeliveryReceiptsManagerTest { } @Test - fun `should skip mark messages as delivered when current user privacy settings are undefined`() { + fun `should skip storing message delivery receipts when current user privacy settings are undefined`() = runTest { val currentUser = randomUser(privacySettings = null) val messages = randomMessageList(10) { randomMessage() } val fixture = Fixture().givenCurrentUser(currentUser) @@ -73,11 +84,11 @@ internal class DeliveryReceiptsManagerTest { sut.markMessagesAsDelivered(messages) - fixture.verifyNoInteractions() + fixture.verifyUpsertNotCalled() } @Test - fun `should skip mark messages as delivered when delivery receipts are disabled`() { + fun `should skip storing message delivery receipts when delivery receipts are disabled`() = runTest { val currentUser = randomUser( privacySettings = PrivacySettings( deliveryReceipts = DeliveryReceipts(enabled = false), @@ -89,78 +100,80 @@ internal class DeliveryReceiptsManagerTest { sut.markMessagesAsDelivered(messages) - fixture.verifyNoInteractions() + fixture.verifyUpsertNotCalled() } @Test - fun `should skip mark messages as delivered with empty list`() { + fun `should skip storing message delivery receipts with empty list`() = runTest { val messages = emptyList() val fixture = Fixture() val sut = fixture.get() sut.markMessagesAsDelivered(messages) - fixture.verifyNoInteractions() + fixture.verifyUpsertNotCalled() } @Test - fun `should skip mark messages from the current user as delivered`() { + fun `should skip storing message delivery receipts from the current user`() = runTest { val messages = randomMessageList(10) { randomMessage(user = CurrentUser) } val fixture = Fixture() val sut = fixture.get() sut.markMessagesAsDelivered(messages) - fixture.verifyNoInteractions() + fixture.verifyUpsertNotCalled() } @Test - fun `should skip mark system messages as delivered`() { + fun `should skip storing message delivery receipts from system messages`() = runTest { val messages = randomMessageList(10) { randomMessage(type = "system") } val fixture = Fixture() val sut = fixture.get() sut.markMessagesAsDelivered(messages) - fixture.verifyNoInteractions() + fixture.verifyUpsertNotCalled() } @Test - fun `should skip mark deleted messages as delivered`() { + fun `should skip storing message delivery receipts from deleted messages`() = runTest { val messages = randomMessageList(10) { randomMessage(deletedAt = Date()) } val fixture = Fixture() val sut = fixture.get() sut.markMessagesAsDelivered(messages) - fixture.verifyNoInteractions() + fixture.verifyUpsertNotCalled() } private class Fixture { - private val mockChatClient = mock { - on { markMessagesAsDelivered(any()) } doReturn Unit.asCall() - } + private val mockMessageReceiptRepository = mock() private var getCurrentUser: () -> User? = { CurrentUser } fun givenCurrentUser(user: User?) = apply { getCurrentUser = { user } } - fun verifyNoInteractions() { - verifyNoInteractions(mockChatClient) + fun verifyUpsertMessageReceiptsCalled(receipts: List) { + verifyBlocking(mockMessageReceiptRepository) { upsert(receipts) } } - fun verifyMarkMessagesAsDelivered(messages: List) { - verify(mockChatClient).markMessagesAsDelivered(messages) + fun verifyUpsertNotCalled() { + verifyBlocking(mockMessageReceiptRepository, never()) { upsert(any()) } } - fun get() = DeliveryReceiptsManager( - chatClient = mockChatClient, + fun get() = MessageReceiptManager( + scope = CoroutineScope(UnconfinedTestDispatcher()), + now = { Now }, getCurrentUser = getCurrentUser, + messageReceiptRepository = mockMessageReceiptRepository, ) } } +private val Now = Date() + private val CurrentUser = randomUser( privacySettings = PrivacySettings( deliveryReceipts = DeliveryReceipts(enabled = true), From e70ca831e8a2663b1894142439e408d88cc68990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 24 Oct 2025 13:46:46 +0100 Subject: [PATCH 09/58] Introduced `MessageReceiptReporter`, a new class responsible for observing the local database for delivery receipts and reporting them to the backend. This reporter collects receipts in batches and sends them periodically to reduce network traffic. After a successful API call, the reported receipts are removed from the local database. New tests are included to verify this functionality. --- .../client/receipts/MessageReceiptReporter.kt | 72 +++++++ .../receipts/MessageReceiptReporterTest.kt | 187 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt new file mode 100644 index 00000000000..ff7023f4be0 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2014-2025 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.receipts + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageReceipt +import io.getstream.log.taggedLogger +import io.getstream.result.onSuccessSuspend +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample + +/** + * Reports message delivery receipts to the server in batches of [MAX_BATCH_SIZE] + * every [REPORT_INTERVAL_IN_MS] milliseconds. + */ +internal class MessageReceiptReporter( + private val scope: CoroutineScope, + private val chatClient: ChatClient, + private val messageReceiptRepository: MessageReceiptRepository, +) { + + private val logger by taggedLogger("MessageReceiptReporter") + + @OptIn(FlowPreview::class) + fun init() { + messageReceiptRepository.getAllByType(type = MessageReceipt.TYPE_DELIVERY, limit = MAX_BATCH_SIZE) + .sample(REPORT_INTERVAL_IN_MS) + .filterNot(List::isEmpty) + .map { receipts -> + receipts.map { receipt -> + Message( + id = receipt.messageId, + cid = receipt.cid, + ) + } + } + .onEach { messages -> + logger.d { "[init] Reporting delivery receipts for ${messages.size} messages…" } + chatClient.markMessagesAsDelivered(messages) + .execute() + .onSuccessSuspend { + val deliveredMessageIds = messages.map(Message::id) + messageReceiptRepository.deleteByMessageIds(deliveredMessageIds) + } + } + .launchIn(scope) + } +} + +private const val REPORT_INTERVAL_IN_MS = 1000L +private const val MAX_BATCH_SIZE = 100 diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt new file mode 100644 index 00000000000..037ec0ed970 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2014-2025 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.receipts + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.MessageReceipt +import io.getstream.chat.android.randomMessageReceipt +import io.getstream.chat.android.test.asCall +import io.getstream.result.Error +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.whenever +import org.mockito.verification.VerificationMode + +@OptIn(ExperimentalCoroutinesApi::class) +internal class MessageReceiptReporterTest { + + @Test + fun `should fetch and send delivery receipts successfully`() = runTest { + val receipts = listOf( + randomMessageReceipt(), + randomMessageReceipt(), + ) + val messages = receipts.map { receipt -> + Message( + id = receipt.messageId, + cid = receipt.cid, + ) + } + val fixture = Fixture() + .givenMessageReceipts(receipts) + .givenMarkMessagesAsDelivered(messages) + val sut = fixture.get(backgroundScope) + + sut.init() + advanceTimeBy(1100) // Advance time to after the interval window + + fixture.verifyMarkMessagesAsDeliveredCalled(messages = messages) + val messageIds = messages.map(Message::id) + fixture.verifyDeleteByMessageIdsCalled(messageIds = messageIds) + } + + @Test + fun `should not delete receipts when marking messages as delivered fails`() = runTest { + val fixture = Fixture() + .givenMessageReceipts(listOf(randomMessageReceipt())) + .givenMarkMessagesAsDelivered(error = mock()) + val sut = fixture.get(backgroundScope) + + sut.init() + advanceTimeBy(1100) // Allow initial execution + + fixture.verifyDeleteByMessageIdsCalled(never()) + + // Keep processing subsequent success emissions + fixture.givenMessageReceipts(listOf(randomMessageReceipt())) + fixture.givenMarkMessagesAsDelivered() + + advanceTimeBy(1100) + + fixture.verifyMarkMessagesAsDeliveredCalled(times(2)) + fixture.verifyDeleteByMessageIdsCalled() + } + + @Test + fun `should handle empty receipt list`() = runTest { + val fixture = Fixture() + .givenMessageReceipts(receipts = emptyList()) + val sut = fixture.get(backgroundScope) + + sut.init() + advanceTimeBy(1100) + + fixture.verifyMarkMessagesAsDeliveredCalled(never()) + fixture.verifyDeleteByMessageIdsCalled(never()) + } + + @Test + fun `should execute periodically with correct delay`() = runTest { + val fixture = Fixture() + .givenMessageReceipts(listOf(randomMessageReceipt())) + .givenMarkMessagesAsDelivered() + val sut = fixture.get(backgroundScope) + + sut.init() + + // Trigger multiple emissions + + advanceTimeBy(1100) // Collecting the first emission + + // Trigger a new list + fixture.givenMessageReceipts(listOf(randomMessageReceipt())) + advanceTimeBy(1000) // Wait for delay + + // Trigger a new list + fixture.givenMessageReceipts(listOf(randomMessageReceipt())) + advanceTimeBy(100) // Collecting the second emission + + // Trigger a new list + fixture.givenMessageReceipts(listOf(randomMessageReceipt())) + advanceTimeBy(1000) // Wait for delay + advanceTimeBy(100) // Collecting the third emission + + fixture.verifyMarkMessagesAsDeliveredCalled(times(3)) + } + + @Test + fun `should stop execution when coroutine scope is cancelled`() = runTest { + val fixture = Fixture() + .givenMessageReceipts(listOf(randomMessageReceipt())) + .givenMarkMessagesAsDelivered() + val sut = fixture.get(backgroundScope) + + sut.init() + advanceTimeBy(1100) // Allow initial execution + + backgroundScope.cancel() + + // Trigger a new list + fixture.givenMessageReceipts(listOf(randomMessageReceipt())) + + advanceTimeBy(2000) // Try to advance time after cancellation + + fixture.verifyMarkMessagesAsDeliveredCalled(times(1)) + } + + private class Fixture { + private val mockChatClient = mock() + + private val receiptsStateFlow = MutableStateFlow>(emptyList()) + + private val mockMessageReceiptRepository = mock { + onBlocking { getAllByType(type = MessageReceipt.TYPE_DELIVERY, limit = 100) } doReturn receiptsStateFlow + } + + fun givenMessageReceipts(receipts: List) = apply { + receiptsStateFlow.value = receipts + } + + fun givenMarkMessagesAsDelivered(messages: List? = null, error: Error? = null) = apply { + whenever(mockChatClient.markMessagesAsDelivered(messages ?: any())) doReturn + (error?.asCall() ?: Unit.asCall()) + } + + fun verifyMarkMessagesAsDeliveredCalled(mode: VerificationMode = times(1), messages: List? = null) { + verify(mockChatClient, mode).markMessagesAsDelivered(messages ?: any()) + } + + fun verifyDeleteByMessageIdsCalled(mode: VerificationMode = times(1), messageIds: List? = null) { + verifyBlocking(mockMessageReceiptRepository, mode) { deleteByMessageIds(messageIds ?: any()) } + } + + fun get(scope: CoroutineScope) = MessageReceiptReporter( + scope = scope, + chatClient = mockChatClient, + messageReceiptRepository = mockMessageReceiptRepository, + ) + } +} From cf2c956de9f3b79eb946b73e2730651915f81809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 27 Oct 2025 14:21:18 +0000 Subject: [PATCH 10/58] Prapare to move internal persistence to the client module --- stream-chat-android-client/build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/stream-chat-android-client/build.gradle.kts b/stream-chat-android-client/build.gradle.kts index 1001e82abaa..796b243a9f3 100644 --- a/stream-chat-android-client/build.gradle.kts +++ b/stream-chat-android-client/build.gradle.kts @@ -97,6 +97,10 @@ dependencies { implementation(libs.okhttp.logging.interceptor) implementation(libs.ok2curl) + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.room.ktx) + ksp(libs.androidx.room.compiler) + // Unused dependencies: The following dependencies (appcompat, constraintlayout, livedata-ktx) are not used in the // `stream-chat-android-client` module. They are still declared here to prevent potential breaking changes for // integrations that might be relying on them transitively. Consider removing them in future major releases. @@ -113,6 +117,7 @@ dependencies { testImplementation(libs.androidx.lifecycle.runtime.testing) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) + testImplementation(libs.turbine) testRuntimeOnly(libs.junit.jupiter.engine) testRuntimeOnly(libs.junit.vintage.engine) From 6b767b4d505ed5c64f497fc04c5b9682685e8671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 27 Oct 2025 15:03:47 +0000 Subject: [PATCH 11/58] Add ChatClientDatabase, DateConverter, and ChatClientRepository for message receipt management --- .../persistence/db/ChatClientDatabase.kt | 56 +++++++++++++++++++ .../persistence/db/converter/DateConverter.kt | 28 ++++++++++ .../repository/ChatClientRepository.kt | 34 +++++++++++ .../repository/ChatClientRepositoryTest.kt | 47 ++++++++++++++++ 4 files changed, 165 insertions(+) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/ChatClientDatabase.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/converter/DateConverter.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/ChatClientDatabase.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/ChatClientDatabase.kt new file mode 100644 index 00000000000..a4c76e90a73 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/ChatClientDatabase.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2014-2025 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.persistence.db + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import androidx.sqlite.db.SupportSQLiteDatabase +import io.getstream.chat.android.client.persistence.db.converter.DateConverter +import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity + +@Database( + entities = [MessageReceiptEntity::class], + version = 1, + exportSchema = false, +) +@TypeConverters( + DateConverter::class, +) +internal abstract class ChatClientDatabase : RoomDatabase() { + abstract fun messageReceiptDao(): MessageReceiptDao + + companion object { + fun build(context: Context) = Room.databaseBuilder( + context = context.applicationContext, + klass = ChatClientDatabase::class.java, + name = "stream_chat_client.db", + ) + .fallbackToDestructiveMigration() + .addCallback( + object : Callback() { + override fun onOpen(db: SupportSQLiteDatabase) { + db.execSQL("PRAGMA synchronous = NORMAL") + } + }, + ) + .build() + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/converter/DateConverter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/converter/DateConverter.kt new file mode 100644 index 00000000000..a1dbbe4b78a --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/converter/DateConverter.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2014-2025 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.persistence.db.converter + +import androidx.room.TypeConverter +import java.util.Date + +internal class DateConverter { + @TypeConverter + fun fromDb(value: Long?): Date? = value?.let(::Date) + + @TypeConverter + fun toDb(date: Date?): Long? = date?.time +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt new file mode 100644 index 00000000000..809d6383405 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2014-2025 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.persistence.repository + +import io.getstream.chat.android.client.persistence.db.ChatClientDatabase + +internal class ChatClientRepository( + private val messageReceiptRepository: MessageReceiptRepository, +) : MessageReceiptRepository by messageReceiptRepository { + + suspend fun clear() { + messageReceiptRepository.clearMessageReceipts() + } + + companion object { + fun from(database: ChatClientDatabase) = ChatClientRepository( + messageReceiptRepository = MessageReceiptRepository(database), + ) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt new file mode 100644 index 00000000000..b5adf17e257 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2014-2025 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.persistence.repository + +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.Mockito.mock +import org.mockito.kotlin.verifyBlocking + +internal class ChatClientRepositoryTest { + + @Test + fun `should clear repositories`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.clear() + + fixture.verifyRepositoriesCleared() + } + + private class Fixture { + private val mockMessageReceiptRepository = mock() + + fun verifyRepositoriesCleared() { + verifyBlocking(mockMessageReceiptRepository) { clearMessageReceipts() } + } + + fun get() = ChatClientRepository( + messageReceiptRepository = mockMessageReceiptRepository, + ) + } +} From 8fa9aebdf2c6a2b13ca8e13ce5365cee1173eee1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 27 Oct 2025 15:05:18 +0000 Subject: [PATCH 12/58] Refactor: Move message receipt logic to client Moves the `MessageReceiptRepository` and related classes from the `stream-chat-android-offline` module to the `stream-chat-android-client` module. This refactoring centralizes persistence logic within the client module. The moved classes have been renamed and their package structure updated. Method names within the repository have been made more specific (e.g., `upsert` is now `upsertMessageReceipts`). The `MessageReceiptRepository` is now an internal interface with a companion object factory. --- .../api/stream-chat-android-client.api | 18 +++++++ .../repository/MessageReceiptRepository.kt | 34 ------------ .../persistence/db/dao/MessageReceiptDao.kt | 40 ++++++++++++++ .../db/entity/MessageReceiptEntity.kt | 30 +++++++++++ .../repository/MessageReceiptRepository.kt | 39 ++++++++++++++ .../MessageReceiptRepositoryImpl.kt | 45 ++++++++++++++++ .../MessageReceiptRepositoryMapper.kt | 34 ++++++++++++ .../client/receipts}/MessageReceipt.kt | 18 +++---- .../client/receipts/MessageReceiptManager.kt | 13 ++--- .../client/receipts/MessageReceiptReporter.kt | 18 +++++-- .../getstream/chat/android/client/Mother.kt | 26 +++++++++ .../MessageReceiptRepositoryImplTest.kt | 54 ++++++++++++------- .../receipts/MessageReceiptManagerTest.kt | 21 +++++--- .../receipts/MessageReceiptReporterTest.kt | 12 +++-- .../api/stream-chat-android-core.api | 22 -------- .../io/getstream/chat/android/Mother.kt | 13 ----- .../api/stream-chat-android-offline.api | 10 ---- .../database/internal/ChatDatabase.kt | 4 -- .../domain/receipts/MessageReceiptDao.kt | 28 ---------- .../domain/receipts/MessageReceiptEntity.kt | 14 ----- .../domain/receipts/MessageReceiptMapper.kt | 17 ------ .../receipts/MessageReceiptRepositoryImpl.kt | 29 ---------- .../getstream/chat/android/offline/Mother.kt | 13 ----- 23 files changed, 316 insertions(+), 236 deletions(-) delete mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageReceiptRepository.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/entity/MessageReceiptEntity.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryMapper.kt rename {stream-chat-android-core/src/main/java/io/getstream/chat/android/models => stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts}/MessageReceipt.kt (65%) rename {stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts => stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository}/MessageReceiptRepositoryImplTest.kt (61%) delete mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt delete mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt delete mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptMapper.kt delete mode 100644 stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.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 5ca9d225ad0..73dcbe1ad85 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -2983,6 +2983,24 @@ public abstract interface class io/getstream/chat/android/client/persistance/rep public abstract fun createRepositoryFactory (Lio/getstream/chat/android/models/User;)Lio/getstream/chat/android/client/persistance/repository/factory/RepositoryFactory; } +public final class io/getstream/chat/android/client/persistence/db/ChatClientDatabase_Impl { + public static final field Companion Lio/getstream/chat/android/client/persistence/db/ChatClientDatabase$Companion; + public fun ()V + public fun clearAllTables ()V + public fun getAutoMigrations (Ljava/util/Map;)Ljava/util/List; + public fun getRequiredAutoMigrationSpecs ()Ljava/util/Set; + public fun messageReceiptDao ()Lio/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao; +} + +public final class io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao_Impl : io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao { + public fun (Landroidx/room/RoomDatabase;)V + public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun deleteByMessageIds (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun getRequiredConverters ()Ljava/util/List; + public fun selectAllByType (Ljava/lang/String;I)Lkotlinx/coroutines/flow/Flow; + public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class io/getstream/chat/android/client/plugin/Plugin : io/getstream/chat/android/client/plugin/DependencyResolver, io/getstream/chat/android/client/plugin/listeners/BlockUserListener, io/getstream/chat/android/client/plugin/listeners/ChannelMarkReadListener, io/getstream/chat/android/client/plugin/listeners/CreateChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteChannelListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageForMeListener, io/getstream/chat/android/client/plugin/listeners/DeleteMessageListener, io/getstream/chat/android/client/plugin/listeners/DeleteReactionListener, io/getstream/chat/android/client/plugin/listeners/DraftMessageListener, io/getstream/chat/android/client/plugin/listeners/EditMessageListener, io/getstream/chat/android/client/plugin/listeners/FetchCurrentUserListener, io/getstream/chat/android/client/plugin/listeners/GetMessageListener, io/getstream/chat/android/client/plugin/listeners/HideChannelListener, io/getstream/chat/android/client/plugin/listeners/LiveLocationListener, io/getstream/chat/android/client/plugin/listeners/MarkAllReadListener, io/getstream/chat/android/client/plugin/listeners/PushPreferencesListener, io/getstream/chat/android/client/plugin/listeners/QueryBlockedUsersListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelListener, io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener, io/getstream/chat/android/client/plugin/listeners/QueryMembersListener, io/getstream/chat/android/client/plugin/listeners/QueryThreadsListener, io/getstream/chat/android/client/plugin/listeners/SendAttachmentListener, io/getstream/chat/android/client/plugin/listeners/SendGiphyListener, io/getstream/chat/android/client/plugin/listeners/SendMessageListener, io/getstream/chat/android/client/plugin/listeners/SendReactionListener, io/getstream/chat/android/client/plugin/listeners/ShuffleGiphyListener, io/getstream/chat/android/client/plugin/listeners/ThreadQueryListener, io/getstream/chat/android/client/plugin/listeners/TypingEventListener, io/getstream/chat/android/client/plugin/listeners/UnblockUserListener { public fun getErrorHandler ()Lio/getstream/chat/android/client/errorhandler/ErrorHandler; public fun onAttachmentSendRequest (Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageReceiptRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageReceiptRepository.kt deleted file mode 100644 index 8d443be3ea4..00000000000 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistance/repository/MessageReceiptRepository.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2014-2025 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.persistance.repository - -import io.getstream.chat.android.core.internal.InternalStreamChatApi -import io.getstream.chat.android.models.MessageReceipt -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.emptyFlow - -@InternalStreamChatApi -public interface MessageReceiptRepository { - - public suspend fun upsert(receipts: List) { /* no-op */ } - - public fun getAllByType(type: String, limit: Int): Flow> = emptyFlow() - - public suspend fun deleteByMessageIds(messageIds: List) { /* no-op */ } - - public suspend fun clear() { /* no-op */ } -} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt new file mode 100644 index 00000000000..2138f8f2c81 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2014-2025 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.persistence.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import kotlinx.coroutines.flow.Flow + +@Dao +internal interface MessageReceiptDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(receipts: List) + + @Query("SELECT * FROM message_receipt WHERE type = :type ORDER BY createdAt ASC LIMIT :limit") + fun selectAllByType(type: String, limit: Int): Flow> + + @Query("DELETE FROM message_receipt WHERE messageId IN (:messageIds)") + suspend fun deleteByMessageIds(messageIds: List) + + @Query("DELETE FROM message_receipt") + suspend fun deleteAll() +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/entity/MessageReceiptEntity.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/entity/MessageReceiptEntity.kt new file mode 100644 index 00000000000..e63f2a06254 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/entity/MessageReceiptEntity.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014-2025 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.persistence.db.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.util.Date + +@Entity(tableName = "message_receipt") +internal data class MessageReceiptEntity( + @PrimaryKey + val messageId: String, + val type: String, + val createdAt: Date, + val cid: String, +) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt new file mode 100644 index 00000000000..ef94d0317f6 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2014-2025 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.persistence.repository + +import io.getstream.chat.android.client.persistence.db.ChatClientDatabase +import io.getstream.chat.android.client.receipts.MessageReceipt +import kotlinx.coroutines.flow.Flow + +internal interface MessageReceiptRepository { + + companion object { + operator fun invoke(database: ChatClientDatabase): MessageReceiptRepository = + MessageReceiptRepositoryImpl( + dao = database.messageReceiptDao(), + ) + } + + suspend fun upsertMessageReceipts(receipts: List) + + fun getAllMessageReceiptsByType(type: String, limit: Int): Flow> + + suspend fun deleteMessageReceiptsByMessageIds(messageIds: List) + + suspend fun clearMessageReceipts() +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt new file mode 100644 index 00000000000..0bb77cf1a33 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2014-2025 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.persistence.repository + +import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import io.getstream.chat.android.client.receipts.MessageReceipt +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +internal class MessageReceiptRepositoryImpl( + private val dao: MessageReceiptDao, +) : MessageReceiptRepository { + + override suspend fun upsertMessageReceipts(receipts: List) { + dao.upsert(receipts.map(MessageReceipt::toEntity)) + } + + override fun getAllMessageReceiptsByType(type: String, limit: Int): Flow> = + dao.selectAllByType(type, limit).map { receipts -> + receipts.map(MessageReceiptEntity::toModel) + } + + override suspend fun deleteMessageReceiptsByMessageIds(messageIds: List) { + dao.deleteByMessageIds(messageIds) + } + + override suspend fun clearMessageReceipts() { + dao.deleteAll() + } +} diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryMapper.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryMapper.kt new file mode 100644 index 00000000000..49b21581848 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryMapper.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2014-2025 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.persistence.repository + +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import io.getstream.chat.android.client.receipts.MessageReceipt + +internal fun MessageReceipt.toEntity() = MessageReceiptEntity( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) + +internal fun MessageReceiptEntity.toModel() = MessageReceipt( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MessageReceipt.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceipt.kt similarity index 65% rename from stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MessageReceipt.kt rename to stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceipt.kt index fe75dbb2131..a70d5226f14 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/MessageReceipt.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceipt.kt @@ -14,19 +14,17 @@ * limitations under the License. */ -package io.getstream.chat.android.models +package io.getstream.chat.android.client.receipts -import androidx.compose.runtime.Immutable import java.util.Date -@Immutable -public data class MessageReceipt( - public val messageId: String, - public val type: String, - public val createdAt: Date, - public val cid: String, +internal data class MessageReceipt( + val messageId: String, + val type: String, + val createdAt: Date, + val cid: String, ) { - public companion object { - public const val TYPE_DELIVERED: String = "delivered" + companion object { + const val TYPE_DELIVERY: String = "delivery" } } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index 831fef988f8..5faeaf23b64 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -16,10 +16,9 @@ package io.getstream.chat.android.client.receipts -import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository +import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.MessageReceipt import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.User import io.getstream.chat.android.models.UserId @@ -29,7 +28,8 @@ import kotlinx.coroutines.launch import java.util.Date /** - * Manages message delivery receipts: creating and storing them in the repository. + * Manages message delivery receipts: creating and storing them in the repository + * for later reporting to the server. */ internal class MessageReceiptManager( private val scope: CoroutineScope, @@ -48,7 +48,7 @@ internal class MessageReceiptManager( } // Check if delivery receipts are enabled for the current user - if (!currentUser.isDeliveryReceiptsEnabled()) { + if (!currentUser.isDeliveryReceiptsEnabled) { logger.w { "[markMessagesAsDelivered] Delivery receipts disabled for user ${currentUser.id}" } return } @@ -71,7 +71,7 @@ internal class MessageReceiptManager( scope.launch { val receipts = filteredMessages.map { message -> message.toDeliveryReceipt() } - messageReceiptRepository.upsert(receipts) + messageReceiptRepository.upsertMessageReceipts(receipts) logger.d { "[markMessagesAsDelivered] ${filteredMessages.size} delivery receipts upserted" } } @@ -103,6 +103,3 @@ internal class MessageReceiptManager( cid = cid, ) } - -private fun User.isDeliveryReceiptsEnabled(): Boolean = - privacySettings?.deliveryReceipts?.enabled ?: false diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt index ff7023f4be0..a90a19dd338 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt @@ -17,9 +17,8 @@ package io.getstream.chat.android.client.receipts import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository +import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.MessageReceipt import io.getstream.log.taggedLogger import io.getstream.result.onSuccessSuspend import kotlinx.coroutines.CoroutineScope @@ -44,7 +43,11 @@ internal class MessageReceiptReporter( @OptIn(FlowPreview::class) fun init() { - messageReceiptRepository.getAllByType(type = MessageReceipt.TYPE_DELIVERY, limit = MAX_BATCH_SIZE) + logger.d { "[init] Handling message receipts reporting…" } + messageReceiptRepository.getAllMessageReceiptsByType( + type = MessageReceipt.TYPE_DELIVERY, + limit = MAX_BATCH_SIZE, + ) .sample(REPORT_INTERVAL_IN_MS) .filterNot(List::isEmpty) .map { receipts -> @@ -60,8 +63,15 @@ internal class MessageReceiptReporter( chatClient.markMessagesAsDelivered(messages) .execute() .onSuccessSuspend { + logger.d { "[init] Successfully reported delivery receipts for ${messages.size} messages" } val deliveredMessageIds = messages.map(Message::id) - messageReceiptRepository.deleteByMessageIds(deliveredMessageIds) + messageReceiptRepository.deleteMessageReceiptsByMessageIds(deliveredMessageIds) + } + .onError { error -> + logger.e { + "[init] Failed to report delivery receipts for ${messages.size} messages: " + + error.message + } } } .launchIn(scope) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 88d6867f63b..b73788a1509 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -81,6 +81,8 @@ import io.getstream.chat.android.client.logger.ChatLoggerConfig import io.getstream.chat.android.client.logger.ChatLoggerHandler import io.getstream.chat.android.client.network.NetworkStateProvider import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateFormatter +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import io.getstream.chat.android.client.receipts.MessageReceipt import io.getstream.chat.android.client.setup.state.internal.MutableClientState import io.getstream.chat.android.client.socket.ChatSocketStateService import io.getstream.chat.android.client.socket.SocketFactory @@ -1271,3 +1273,27 @@ internal object Mother { disabled_until = disabledUntil, ) } + +internal fun randomMessageReceipt( + messageId: String = randomString(), + type: String = randomString(), + createdAt: Date = randomDate(), + cid: String = randomCID(), +) = MessageReceipt( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) + +internal fun randomMessageReceiptEntity( + messageId: String = randomString(), + type: String = randomString(), + createdAt: Date = randomDate(), + cid: String = randomCID(), +) = MessageReceiptEntity( + messageId = messageId, + type = type, + createdAt = createdAt, + cid = cid, +) diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt similarity index 61% rename from stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt rename to stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt index e7e8fef730a..4bd02a52d63 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt @@ -1,15 +1,33 @@ -package io.getstream.chat.android.offline.repository.domain.receipts +/* + * Copyright (c) 2014-2025 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.persistence.repository import app.cash.turbine.test -import io.getstream.chat.android.models.MessageReceipt -import io.getstream.chat.android.offline.randomMessageReceiptEntity +import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao +import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity +import io.getstream.chat.android.client.randomMessageReceipt +import io.getstream.chat.android.client.randomMessageReceiptEntity +import io.getstream.chat.android.client.receipts.MessageReceipt import io.getstream.chat.android.randomInt -import io.getstream.chat.android.randomMessageReceipt import io.getstream.chat.android.randomString import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Test -import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock @@ -19,12 +37,12 @@ import org.mockito.kotlin.wheneverBlocking internal class MessageReceiptRepositoryImplTest { @Test - fun `upsert receipts`() = runTest { + fun `upsert message receipts`() = runTest { val receipt = randomMessageReceipt() val fixture = Fixture() val sut = fixture.get() - sut.upsert(receipts = listOf(receipt)) + sut.upsertMessageReceipts(receipts = listOf(receipt)) val expectedReceipts = listOf( MessageReceiptEntity( @@ -32,25 +50,25 @@ internal class MessageReceiptRepositoryImplTest { type = receipt.type, createdAt = receipt.createdAt, cid = receipt.cid, - ) + ), ) fixture.verifyUpsertCalled(expectedReceipts) } @Test - fun `get receipts by type`() = runTest { + fun `get message receipts by type`() = runTest { val type = randomString() val limit = randomInt() val receipt = randomMessageReceiptEntity() val fixture = Fixture() - .givenReceiptsByType( + .givenMessageReceiptsByType( type = type, limit = limit, receipts = listOf(receipt), ) val sut = fixture.get() - sut.getAllByType(type, limit).test { + sut.getAllMessageReceiptsByType(type, limit).test { val actual = awaitItem() val expected = listOf( @@ -59,29 +77,29 @@ internal class MessageReceiptRepositoryImplTest { type = receipt.type, createdAt = receipt.createdAt, cid = receipt.cid, - ) + ), ) - assertEquals(expected, actual) + Assertions.assertEquals(expected, actual) } } @Test - fun `delete receipts by message IDs`() = runTest { + fun `delete message receipts by message IDs`() = runTest { val messageIds = listOf(randomString()) val fixture = Fixture() val sut = fixture.get() - sut.deleteByMessageIds(messageIds) + sut.deleteMessageReceiptsByMessageIds(messageIds) fixture.verifyDeleteByMessageIdsCalled(messageIds) } @Test - fun `clear receipts`() = runTest { + fun `clear message receipts`() = runTest { val fixture = Fixture() val sut = fixture.get() - sut.clear() + sut.clearMessageReceipts() fixture.verifyDeleteAllCalled() } @@ -95,7 +113,7 @@ internal class MessageReceiptRepositoryImplTest { onBlocking { deleteByMessageIds(any()) } doReturn Unit } - fun givenReceiptsByType(type: String, limit: Int, receipts: List) = apply { + fun givenMessageReceiptsByType(type: String, limit: Int, receipts: List) = apply { wheneverBlocking { mockDao.selectAllByType(type, limit) } doReturn receiptsStateFlow.apply { value = receipts } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index 598efa365d4..8154e803c2c 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -18,9 +18,8 @@ package io.getstream.chat.android.client.receipts import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings -import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository +import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.MessageReceipt import io.getstream.chat.android.models.User import io.getstream.chat.android.randomDate import io.getstream.chat.android.randomMessage @@ -76,15 +75,23 @@ internal class MessageReceiptManagerTest { } @Test - fun `should skip storing message delivery receipts when current user privacy settings are undefined`() = runTest { + fun `should store message delivery receipts when current user privacy settings are undefined`() = runTest { val currentUser = randomUser(privacySettings = null) - val messages = randomMessageList(10) { randomMessage() } + val messages = randomMessageList(10) { randomMessage(deletedAt = null, deletedForMe = false) } val fixture = Fixture().givenCurrentUser(currentUser) val sut = fixture.get() sut.markMessagesAsDelivered(messages) - fixture.verifyUpsertNotCalled() + val receipts = messages.map { message -> + MessageReceipt( + messageId = message.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = message.cid, + ) + } + fixture.verifyUpsertMessageReceiptsCalled(receipts) } @Test @@ -156,11 +163,11 @@ internal class MessageReceiptManagerTest { } fun verifyUpsertMessageReceiptsCalled(receipts: List) { - verifyBlocking(mockMessageReceiptRepository) { upsert(receipts) } + verifyBlocking(mockMessageReceiptRepository) { upsertMessageReceipts(receipts) } } fun verifyUpsertNotCalled() { - verifyBlocking(mockMessageReceiptRepository, never()) { upsert(any()) } + verifyBlocking(mockMessageReceiptRepository, never()) { upsertMessageReceipts(any()) } } fun get() = MessageReceiptManager( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt index 037ec0ed970..6f75759a6ff 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt @@ -17,10 +17,9 @@ package io.getstream.chat.android.client.receipts import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository +import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository +import io.getstream.chat.android.client.randomMessageReceipt import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.MessageReceipt -import io.getstream.chat.android.randomMessageReceipt import io.getstream.chat.android.test.asCall import io.getstream.result.Error import kotlinx.coroutines.CoroutineScope @@ -158,7 +157,8 @@ internal class MessageReceiptReporterTest { private val receiptsStateFlow = MutableStateFlow>(emptyList()) private val mockMessageReceiptRepository = mock { - onBlocking { getAllByType(type = MessageReceipt.TYPE_DELIVERY, limit = 100) } doReturn receiptsStateFlow + onBlocking { getAllMessageReceiptsByType(type = MessageReceipt.TYPE_DELIVERY, limit = 100) } doReturn + receiptsStateFlow } fun givenMessageReceipts(receipts: List) = apply { @@ -175,7 +175,9 @@ internal class MessageReceiptReporterTest { } fun verifyDeleteByMessageIdsCalled(mode: VerificationMode = times(1), messageIds: List? = null) { - verifyBlocking(mockMessageReceiptRepository, mode) { deleteByMessageIds(messageIds ?: any()) } + verifyBlocking(mockMessageReceiptRepository, mode) { + deleteMessageReceiptsByMessageIds(messageIds ?: any()) + } } fun get(scope: CoroutineScope) = MessageReceiptReporter( diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 7df233a302f..02aaec1b8bf 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -1439,28 +1439,6 @@ public final class io/getstream/chat/android/models/MessageModerationDetails { public fun toString ()Ljava/lang/String; } -public final class io/getstream/chat/android/models/MessageReceipt { - public static final field Companion Lio/getstream/chat/android/models/MessageReceipt$Companion; - public static final field TYPE_DELIVERED Ljava/lang/String; - public fun (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)V - public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun component3 ()Ljava/util/Date; - public final fun component4 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/models/MessageReceipt; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/MessageReceipt;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/MessageReceipt; - public fun equals (Ljava/lang/Object;)Z - public final fun getCid ()Ljava/lang/String; - public final fun getCreatedAt ()Ljava/util/Date; - public final fun getMessageId ()Ljava/lang/String; - public final fun getType ()Ljava/lang/String; - public fun hashCode ()I - public fun toString ()Ljava/lang/String; -} - -public final class io/getstream/chat/android/models/MessageReceipt$Companion { -} - public final class io/getstream/chat/android/models/MessageReminder : io/getstream/chat/android/models/querysort/ComparableFieldProvider { public fun (Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/Channel;Ljava/lang/String;Lio/getstream/chat/android/models/Message;Ljava/util/Date;Ljava/util/Date;)V public final fun component1 ()Ljava/util/Date; diff --git a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt index e6035bd6a8b..65c607760f7 100644 --- a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt +++ b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt @@ -41,7 +41,6 @@ import io.getstream.chat.android.models.MemberData import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageModerationAction import io.getstream.chat.android.models.MessageModerationDetails -import io.getstream.chat.android.models.MessageReceipt import io.getstream.chat.android.models.MessageReminder import io.getstream.chat.android.models.MessageReminderInfo import io.getstream.chat.android.models.Moderation @@ -1150,15 +1149,3 @@ public fun attachmentTypes(): List = listOf( AttachmentType.AUDIO_RECORDING, AttachmentType.UNKNOWN, ) - -public fun randomMessageReceipt( - messageId: String = randomString(), - type: String = randomString(), - createdAt: Date = randomDate(), - cid: String = randomCID(), -): MessageReceipt = MessageReceipt( - messageId = messageId, - type = type, - createdAt = createdAt, - cid = cid, -) diff --git a/stream-chat-android-offline/api/stream-chat-android-offline.api b/stream-chat-android-offline/api/stream-chat-android-offline.api index 8b0c120ab9c..f9c6222b504 100644 --- a/stream-chat-android-offline/api/stream-chat-android-offline.api +++ b/stream-chat-android-offline/api/stream-chat-android-offline.api @@ -22,7 +22,6 @@ public final class io/getstream/chat/android/offline/repository/database/interna public fun getAutoMigrations (Ljava/util/Map;)Ljava/util/List; public fun getRequiredAutoMigrationSpecs ()Ljava/util/Set; public fun messageDao ()Lio/getstream/chat/android/offline/repository/domain/message/internal/MessageDao; - public fun messageReceiptDao ()Lio/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao; public fun pollDao ()Lio/getstream/chat/android/offline/repository/domain/message/internal/PollDao; public fun queryChannelsDao ()Lio/getstream/chat/android/offline/repository/domain/queryChannels/internal/QueryChannelsDao; public fun reactionDao ()Lio/getstream/chat/android/offline/repository/domain/reaction/internal/ReactionDao; @@ -188,15 +187,6 @@ public final class io/getstream/chat/android/offline/repository/domain/reaction/ public fun setDeleteAt (Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public final class io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao_Impl : io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao { - public fun (Landroidx/room/RoomDatabase;)V - public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun deleteByMessageIds (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static fun getRequiredConverters ()Ljava/util/List; - public fun selectAllByType (Ljava/lang/String;I)Lkotlinx/coroutines/flow/Flow; - public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; -} - public final class io/getstream/chat/android/offline/repository/domain/syncState/internal/SyncStateDao_Impl : io/getstream/chat/android/offline/repository/domain/syncState/internal/SyncStateDao { public fun (Landroidx/room/RoomDatabase;)V public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt index fff73808bf3..94908b33bf0 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/database/internal/ChatDatabase.kt @@ -60,8 +60,6 @@ import io.getstream.chat.android.offline.repository.domain.queryChannels.interna import io.getstream.chat.android.offline.repository.domain.queryChannels.internal.QueryChannelsEntity import io.getstream.chat.android.offline.repository.domain.reaction.internal.ReactionDao import io.getstream.chat.android.offline.repository.domain.reaction.internal.ReactionEntity -import io.getstream.chat.android.offline.repository.domain.receipts.MessageReceiptDao -import io.getstream.chat.android.offline.repository.domain.receipts.MessageReceiptEntity import io.getstream.chat.android.offline.repository.domain.syncState.internal.SyncStateDao import io.getstream.chat.android.offline.repository.domain.syncState.internal.SyncStateEntity import io.getstream.chat.android.offline.repository.domain.threads.internal.ThreadDao @@ -88,7 +86,6 @@ import io.getstream.chat.android.offline.repository.domain.user.internal.UserEnt ThreadEntity::class, ThreadOrderEntity::class, DraftMessageEntity::class, - MessageReceiptEntity::class, ], version = 96, exportSchema = false, @@ -127,7 +124,6 @@ internal abstract class ChatDatabase : RoomDatabase() { abstract fun pollDao(): PollDao abstract fun threadDao(): ThreadDao abstract fun threadOrderDao(): ThreadOrderDao - abstract fun messageReceiptDao(): MessageReceiptDao companion object { @Volatile diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt deleted file mode 100644 index 3f7c8f022cc..00000000000 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptDao.kt +++ /dev/null @@ -1,28 +0,0 @@ -package io.getstream.chat.android.offline.repository.domain.receipts - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import kotlinx.coroutines.flow.Flow - -@Dao -internal interface MessageReceiptDao { - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsert(receipts: List) - - @Query( - "SELECT * FROM stream_chat_message_receipt " + - "WHERE type = :type " + - "ORDER BY createdAt ASC " + - "LIMIT :limit" - ) - fun selectAllByType(type: String, limit: Int): Flow> - - @Query("DELETE FROM stream_chat_message_receipt WHERE messageId IN (:messageIds)") - suspend fun deleteByMessageIds(messageIds: List) - - @Query("DELETE FROM stream_chat_message_receipt") - suspend fun deleteAll() -} diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt deleted file mode 100644 index b885da6501a..00000000000 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptEntity.kt +++ /dev/null @@ -1,14 +0,0 @@ -package io.getstream.chat.android.offline.repository.domain.receipts - -import androidx.room.Entity -import androidx.room.PrimaryKey -import java.util.Date - -@Entity(tableName = "stream_chat_message_receipt") -internal data class MessageReceiptEntity( - @PrimaryKey - val messageId: String, - val type: String, - val createdAt: Date, - val cid: String, -) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptMapper.kt deleted file mode 100644 index b1e1e6228f6..00000000000 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptMapper.kt +++ /dev/null @@ -1,17 +0,0 @@ -package io.getstream.chat.android.offline.repository.domain.receipts - -import io.getstream.chat.android.models.MessageReceipt - -internal fun MessageReceipt.toEntity() = MessageReceiptEntity( - messageId = messageId, - type = type, - createdAt = createdAt, - cid = cid, -) - -internal fun MessageReceiptEntity.toModel() = MessageReceipt( - messageId = messageId, - type = type, - createdAt = createdAt, - cid = cid, -) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt deleted file mode 100644 index b7e50f63d63..00000000000 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/receipts/MessageReceiptRepositoryImpl.kt +++ /dev/null @@ -1,29 +0,0 @@ -package io.getstream.chat.android.offline.repository.domain.receipts - -import io.getstream.chat.android.client.persistance.repository.MessageReceiptRepository -import io.getstream.chat.android.models.MessageReceipt -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -internal class MessageReceiptRepositoryImpl( - private val dao: MessageReceiptDao, -) : MessageReceiptRepository { - - override suspend fun upsert(receipts: List) { - dao.upsert(receipts.map(MessageReceipt::toEntity)) - } - - override fun getAllByType(type: String, limit: Int): Flow> = - dao.selectAllByType(type, limit) - .map { receipts -> - receipts.map(MessageReceiptEntity::toModel) - } - - override suspend fun deleteByMessageIds(messageIds: List) { - dao.deleteByMessageIds(messageIds) - } - - override suspend fun clear() { - dao.deleteAll() - } -} diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt index 4d0c71907bc..88040f0624d 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/Mother.kt @@ -34,7 +34,6 @@ import io.getstream.chat.android.offline.repository.domain.message.internal.Reac import io.getstream.chat.android.offline.repository.domain.message.internal.ReminderInfoEntity import io.getstream.chat.android.offline.repository.domain.queryChannels.internal.QueryChannelsEntity import io.getstream.chat.android.offline.repository.domain.reaction.internal.ReactionEntity -import io.getstream.chat.android.offline.repository.domain.receipts.MessageReceiptEntity import io.getstream.chat.android.offline.repository.domain.threads.internal.ThreadEntity import io.getstream.chat.android.offline.repository.domain.user.internal.PrivacySettingsEntity import io.getstream.chat.android.offline.repository.domain.user.internal.UserEntity @@ -274,15 +273,3 @@ internal fun randomThreadEntity( latestReplyIds = latestReplyIds, extraData = extraData, ) - -internal fun randomMessageReceiptEntity( - messageId: String = randomString(), - type: String = randomString(), - createdAt: Date = randomDate(), - cid: String = randomCID(), -) = MessageReceiptEntity( - messageId = messageId, - type = type, - createdAt = createdAt, - cid = cid, -) From ee0aca6e1825f0f6edec50b00652c074159b46c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Oct 2025 10:04:40 +0000 Subject: [PATCH 13/58] Support `message.delivered` event This commit adds support for the `message.delivered` event, which is triggered when a message is marked as delivered. The following changes are included: - A new `MessageDeliveredEvent` data class. - New fields (`last_delivered_at`, `last_delivered_message_id`) to the `ChannelUserRead` DTO and model. - Logic to parse and map the `message.delivered` event from the backend DTO to the domain model. --- .../chat/android/client/test/Mother.kt | 21 +++++ .../api/stream-chat-android-client.api | 27 +++++++ .../client/api2/mapping/DomainMapping.kt | 2 + .../client/api2/mapping/EventMapping.kt | 20 +++++ .../api2/model/dto/ChannelUserReadDtos.kt | 2 + .../client/api2/model/dto/EventDtos.kt | 12 +++ .../android/client/channel/ChannelClient.kt | 2 + .../chat/android/client/events/ChatEvent.kt | 15 ++++ .../client/parser2/adapters/EventAdapter.kt | 3 + .../getstream/chat/android/client/Mother.kt | 9 ++- .../client/api2/mapping/DomainMappingTest.kt | 76 +++++++++---------- .../api2/mapping/EventMappingTestArguments.kt | 27 +++++++ .../api/stream-chat-android-core.api | 12 ++- .../chat/android/models/ChannelUserRead.kt | 8 +- .../chat/android/models/EventType.kt | 1 + .../io/getstream/chat/android/Mother.kt | 7 +- .../internal/ChannelUserReadEntity.kt | 2 + .../internal/ChannelUserReadMapper.kt | 20 ++++- .../database/converter/MapConverterTest.kt | 12 ++- .../handler/internal/utils/ChatEventUtils.kt | 14 ++++ .../logic/channel/internal/ChannelLogic.kt | 3 + .../channel/internal/ChannelStateLogic.kt | 9 +++ .../channel/internal/ChannelMutableState.kt | 20 ++++- .../internal/utils/ChatEventUtilsTest.kt | 40 ++++++++++ .../channel/internal/ChannelStateLogicTest.kt | 9 +++ .../internal/ChannelMutableStateTests.kt | 48 ++++++++++++ 26 files changed, 372 insertions(+), 49 deletions(-) create mode 100644 stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtilsTest.kt 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 af212bc8864..f8151d12dbf 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 @@ -27,6 +27,7 @@ import io.getstream.chat.android.client.events.ConnectedEvent import io.getstream.chat.android.client.events.MarkAllReadEvent import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -260,6 +261,26 @@ public fun randomMessageReadEvent( ) } +public fun randomMessageDeliveredEvent( + createdAt: Date = Date(), + user: User = randomUser(), + cid: String = randomCID(), + channelType: String = randomString(), + channelId: String = randomString(), + lastDeliveredAt: Date = randomDate(), + lastDeliveredMessageId: String = randomString(), +) = MessageDeliveredEvent( + type = EventType.MESSAGE_DELIVERED, + createdAt = createdAt, + rawCreatedAt = streamFormatter.format(createdAt), + user = user, + cid = cid, + channelType = channelType, + channelId = channelId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, +) + public fun randomNotificationMarkReadEvent( createdAt: Date = Date(), user: User = randomUser(), 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 73dcbe1ad85..2c949329278 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -1620,6 +1620,33 @@ public final class io/getstream/chat/android/client/events/MessageDeletedEvent : public fun toString ()Ljava/lang/String; } +public final class io/getstream/chat/android/client/events/MessageDeliveredEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/UserEvent { + public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Date; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Lio/getstream/chat/android/models/User; + public final fun component5 ()Ljava/lang/String; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun component8 ()Ljava/util/Date; + public final fun component9 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/client/events/MessageDeliveredEvent; + public static synthetic fun copy$default (Lio/getstream/chat/android/client/events/MessageDeliveredEvent;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/client/events/MessageDeliveredEvent; + public fun equals (Ljava/lang/Object;)Z + public fun getChannelId ()Ljava/lang/String; + public fun getChannelType ()Ljava/lang/String; + public fun getCid ()Ljava/lang/String; + public fun getCreatedAt ()Ljava/util/Date; + public final fun getLastDeliveredAt ()Ljava/util/Date; + public final fun getLastDeliveredMessageId ()Ljava/lang/String; + public fun getRawCreatedAt ()Ljava/lang/String; + public fun getType ()Ljava/lang/String; + public fun getUser ()Lio/getstream/chat/android/models/User; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/client/events/MessageReadEvent : io/getstream/chat/android/client/events/CidEvent, io/getstream/chat/android/client/events/UserEvent { public fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;)V public synthetic fun (Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;Lio/getstream/chat/android/models/User;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/models/ThreadInfo;ILkotlin/jvm/internal/DefaultConstructorMarker;)V diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index 49598a56058..e270b8fba1c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -527,6 +527,8 @@ internal class DomainMapping( lastRead = last_read, unreadMessages = unread_messages, lastReadMessageId = last_read_message_id, + lastDeliveredAt = last_delivered_at, + lastDeliveredMessageId = last_delivered_message_id, ) /** diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt index 1119cadf93a..9199590eae6 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/EventMapping.kt @@ -46,6 +46,7 @@ import io.getstream.chat.android.client.api2.model.dto.MemberAddedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberRemovedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto +import io.getstream.chat.android.client.api2.model.dto.MessageDeliveredEventDto import io.getstream.chat.android.client.api2.model.dto.MessageReadEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto @@ -112,6 +113,7 @@ import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent import io.getstream.chat.android.client.events.MemberUpdatedEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -189,6 +191,7 @@ internal class EventMapping( is MemberRemovedEventDto -> toDomain() is MemberUpdatedEventDto -> toDomain() is MessageDeletedEventDto -> toDomain() + is MessageDeliveredEventDto -> toDomain() is MessageReadEventDto -> toDomain() is MessageUpdatedEventDto -> toDomain() is NotificationAddedToChannelEventDto -> toDomain() @@ -407,6 +410,23 @@ internal class EventMapping( ) } + /** + * Transforms [MessageDeliveredEventDto] to [MessageDeliveredEvent]. + */ + private fun MessageDeliveredEventDto.toDomain() = with(domainMapping) { + MessageDeliveredEvent( + type = type, + createdAt = created_at.date, + rawCreatedAt = created_at.rawDate, + user = user.toDomain(), + cid = cid, + channelType = channel_type, + channelId = channel_id, + lastDeliveredAt = last_delivered_at.date, + lastDeliveredMessageId = last_delivered_message_id, + ) + } + /** * Transforms [MessageReadEventDto] to [MessageReadEvent]. */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelUserReadDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelUserReadDtos.kt index 10e3f11205e..414d88460a5 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelUserReadDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ChannelUserReadDtos.kt @@ -32,4 +32,6 @@ internal data class DownstreamChannelUserRead( val last_read: Date, val unread_messages: Int, val last_read_message_id: String?, + val last_delivered_at: Date? = null, + val last_delivered_message_id: String? = null, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt index 3594637957c..5d69b19df32 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/EventDtos.kt @@ -146,6 +146,18 @@ internal data class MessageDeletedEventDto( val deleted_for_me: Boolean? = null, ) : ChatEventDto() +@JsonClass(generateAdapter = true) +internal data class MessageDeliveredEventDto( + val type: String, + val created_at: ExactDate, + val user: DownstreamUserDto, + val cid: String, + val channel_type: String, + val channel_id: String, + val last_delivered_at: ExactDate, + val last_delivered_message_id: String, +) : ChatEventDto() + @JsonClass(generateAdapter = true) internal data class MessageReadEventDto( val type: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt index a1220168352..a693ecf2658 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/channel/ChannelClient.kt @@ -52,6 +52,7 @@ import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent import io.getstream.chat.android.client.events.MemberUpdatedEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -271,6 +272,7 @@ public class ChannelClient internal constructor( is MemberUpdatedEvent -> event.cid == cid is MessageDeletedEvent -> event.cid == cid is MessageReadEvent -> event.cid == cid + is MessageDeliveredEvent -> event.cid == cid is MessageUpdatedEvent -> event.cid == cid is NewMessageEvent -> event.cid == cid is NotificationAddedToChannelEvent -> event.cid == cid diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt index ae25818ccd1..6eef50a9a16 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/events/ChatEvent.kt @@ -295,6 +295,21 @@ public data class MessageReadEvent( val thread: ThreadInfo? = null, ) : CidEvent(), UserEvent +/** + * Triggered when a message is marked as delivered + */ +public data class MessageDeliveredEvent( + override val type: String, + override val createdAt: Date, + override val rawCreatedAt: String, + override val user: User, + override val cid: String, + override val channelType: String, + override val channelId: String, + val lastDeliveredAt: Date, + val lastDeliveredMessageId: String, +) : CidEvent(), UserEvent + /** * Triggered when a message is updated */ diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt index 04f0eeb4b3d..5b3eca70e78 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/parser2/adapters/EventAdapter.kt @@ -48,6 +48,7 @@ import io.getstream.chat.android.client.api2.model.dto.MemberAddedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberRemovedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto +import io.getstream.chat.android.client.api2.model.dto.MessageDeliveredEventDto import io.getstream.chat.android.client.api2.model.dto.MessageReadEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto @@ -115,6 +116,7 @@ internal class EventDtoAdapter( private val messageDeletedEventAdapter = moshi.adapter(MessageDeletedEventDto::class.java) private val messageUpdatedEventAdapter = moshi.adapter(MessageUpdatedEventDto::class.java) private val messageReadEventAdapter = moshi.adapter(MessageReadEventDto::class.java) + private val messageDeliveredEventAdapter = moshi.adapter(MessageDeliveredEventDto::class.java) private val typingStartEventAdapter = moshi.adapter(TypingStartEventDto::class.java) private val typingStopEventAdapter = moshi.adapter(TypingStopEventDto::class.java) private val reactionNewEventAdapter = moshi.adapter(ReactionNewEventDto::class.java) @@ -196,6 +198,7 @@ internal class EventDtoAdapter( map.containsKey("cid") -> messageReadEventAdapter else -> markAllReadEventAdapter } + EventType.MESSAGE_DELIVERED -> messageDeliveredEventAdapter EventType.TYPING_START -> typingStartEventAdapter EventType.TYPING_STOP -> typingStopEventAdapter EventType.REACTION_NEW -> reactionNewEventAdapter diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index b73788a1509..9ba2a6680d1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -104,6 +104,7 @@ import io.getstream.chat.android.randomExtraData import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomPendingMessageMetadata import io.getstream.chat.android.randomString +import io.getstream.chat.android.randomStringOrNull import io.getstream.chat.android.randomUser import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody @@ -716,12 +717,16 @@ internal object Mother { user: DownstreamUserDto = randomDownstreamUserDto(), lastRead: Date = randomDate(), unreadMessages: Int = randomInt(), - lastReadMessageId: String? = randomString(), - ): DownstreamChannelUserRead = DownstreamChannelUserRead( + lastReadMessageId: String? = randomStringOrNull(), + lastDeliveredAt: Date? = randomDateOrNull(), + lastDeliveredMessageId: String? = randomStringOrNull(), + ) = DownstreamChannelUserRead( user = user, last_read = lastRead, unread_messages = unreadMessages, last_read_message_id = lastReadMessageId, + last_delivered_at = lastDeliveredAt, + last_delivered_message_id = lastDeliveredMessageId, ) fun randomAttachmentDto( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index fa7ef216cf7..89370f19131 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -113,9 +113,7 @@ import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomPendingMessageMetadata import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser -import org.amshove.kluent.`should be equal to` -import org.amshove.kluent.shouldBeEqualTo -import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -135,7 +133,7 @@ internal class DomainMappingTest { randomDownstreamMessageDto().toDomain() } - result `should be equal to` transformedMessage + assertEquals(transformedMessage, result) } @Test @@ -158,7 +156,7 @@ internal class DomainMappingTest { ).toDomain() } - result `should be equal to` transformedMessage + assertEquals(transformedMessage, result) } @Test @@ -187,7 +185,7 @@ internal class DomainMappingTest { draftMessageResponse.toDomain() } - result `should be equal to` expectedMappedDraftMessage + assertEquals(expectedMappedDraftMessage, result) } @Test @@ -199,7 +197,7 @@ internal class DomainMappingTest { metadata = downstreamPendingMessageDto.metadata.orEmpty(), ) val result = with(sut) { downstreamPendingMessageDto.toDomain() } - Assertions.assertEquals(expected, result) + assertEquals(expected, result) } @Test @@ -213,7 +211,7 @@ internal class DomainMappingTest { metadata = pendingMessageMetadata, ) val result = with(sut) { messageResponse.toDomain() } - Assertions.assertEquals(expected, result) + assertEquals(expected, result) } @Test @@ -229,7 +227,7 @@ internal class DomainMappingTest { randomDownstreamUserDto().toDomain() } - result `should be equal to` transformedUser + assertEquals(transformedUser, result) } @Test @@ -245,7 +243,7 @@ internal class DomainMappingTest { randomDownstreamChannelDto().toDomain() } - result `should be equal to` transformedChannel + assertEquals(transformedChannel, result) } @Test @@ -275,7 +273,7 @@ internal class DomainMappingTest { ) with(sut) { - response.toDomain() `should be equal to` expected + assertEquals(expected, response.toDomain()) } } @@ -298,7 +296,7 @@ internal class DomainMappingTest { deletedAt = null, emojiCode = downstreamReactionDto.emoji_code, ) - reaction shouldBeEqualTo expected + assertEquals(expected, reaction) } @Test @@ -315,7 +313,7 @@ internal class DomainMappingTest { updatedAt = downstreamMuteDto.updated_at, expires = downstreamMuteDto.expires, ) - mute shouldBeEqualTo expected + assertEquals(expected, mute) } @Test @@ -332,7 +330,7 @@ internal class DomainMappingTest { updatedAt = downstreamMuteDto.updated_at, expires = downstreamMuteDto.expires, ) - mute shouldBeEqualTo expected + assertEquals(expected, mute) } @Test @@ -350,7 +348,7 @@ internal class DomainMappingTest { firstReactionAt = downstreamReactionGroupDto.first_reaction_at, lastReactionAt = downstreamReactionGroupDto.last_reaction_at, ) - reactionGroup shouldBeEqualTo expected + assertEquals(expected, reactionGroup) } @Test @@ -377,7 +375,7 @@ internal class DomainMappingTest { archivedAt = downstreamMemberDto.archived_at, extraData = downstreamMemberDto.extraData, ) - member shouldBeEqualTo expected + assertEquals(expected, member) } @Test @@ -456,7 +454,7 @@ internal class DomainMappingTest { ), ), ) - poll shouldBeEqualTo expected + assertEquals(expected, poll) } @Test @@ -464,7 +462,7 @@ internal class DomainMappingTest { val value = "public" val sut = Fixture().get() val votingVisibility = with(sut) { value.toVotingVisibility() } - votingVisibility shouldBeEqualTo VotingVisibility.PUBLIC + assertEquals(VotingVisibility.PUBLIC, votingVisibility) } @Test @@ -472,7 +470,7 @@ internal class DomainMappingTest { val value = "anonymous" val sut = Fixture().get() val votingVisibility = with(sut) { value.toVotingVisibility() } - votingVisibility shouldBeEqualTo VotingVisibility.ANONYMOUS + assertEquals(VotingVisibility.ANONYMOUS, votingVisibility) } @Test @@ -498,9 +496,11 @@ internal class DomainMappingTest { unreadMessages = downstreamChannelUserRead.unread_messages, lastReadMessageId = downstreamChannelUserRead.last_read_message_id, lastReceivedEventDate = lastReceivedEventDate, + lastDeliveredAt = downstreamChannelUserRead.last_delivered_at, + lastDeliveredMessageId = downstreamChannelUserRead.last_delivered_message_id, ) - channelUserRead shouldBeEqualTo expected + assertEquals(expected, channelUserRead) } @Test @@ -530,7 +530,7 @@ internal class DomainMappingTest { originalWidth = attachmentDto.original_width, extraData = attachmentDto.extraData.toMutableMap(), ) - attachment shouldBeEqualTo expected + assertEquals(expected, attachment) } @Test @@ -547,7 +547,7 @@ internal class DomainMappingTest { shadow = bannedUserResponse.shadow, reason = bannedUserResponse.reason, ) - bannedUser shouldBeEqualTo expected + assertEquals(expected, bannedUser) } @Test @@ -563,7 +563,7 @@ internal class DomainMappingTest { memberCount = channelInfoDto.member_count, image = channelInfoDto.image, ) - channelInfo shouldBeEqualTo expected + assertEquals(expected, channelInfo) } @Test @@ -577,7 +577,7 @@ internal class DomainMappingTest { args = commandDto.args, set = commandDto.set, ) - command shouldBeEqualTo expected + assertEquals(expected, command) } @Test @@ -612,7 +612,7 @@ internal class DomainMappingTest { sharedLocationsEnabled = configDto.shared_locations ?: false, markMessagesPending = configDto.mark_messages_pending, ) - config shouldBeEqualTo expected + assertEquals(expected, config) } @Test @@ -625,7 +625,7 @@ internal class DomainMappingTest { pushProvider = PushProvider.fromKey(deviceDto.id), providerName = deviceDto.push_provider_name, ) - device shouldBeEqualTo expected + assertEquals(expected, device) } @Test @@ -645,7 +645,7 @@ internal class DomainMappingTest { approvedAt = downstreamFlagDto.approved_at, rejectedAt = downstreamFlagDto.rejected_at, ) - flag shouldBeEqualTo expected + assertEquals(expected, flag) } @Test @@ -658,7 +658,7 @@ internal class DomainMappingTest { action = MessageModerationAction(downstreamModerationDetailsDto.action.orEmpty()), errorMsg = downstreamModerationDetailsDto.error_msg.orEmpty(), ) - moderationDetails shouldBeEqualTo expected + assertEquals(expected, moderationDetails) } @Test @@ -675,7 +675,7 @@ internal class DomainMappingTest { semanticFilterMatched = downstreamModerationDto.semantic_filter_matched, platformCircumvented = downstreamModerationDto.platform_circumvented ?: false, ) - moderation shouldBeEqualTo expected + assertEquals(expected, moderation) } @Test @@ -687,7 +687,7 @@ internal class DomainMappingTest { typingIndicators = TypingIndicators(enabled = privacySettingsDto.typing_indicators?.enabled == true), readReceipts = ReadReceipts(enabled = privacySettingsDto.read_receipts?.enabled == true), ) - privacySettings shouldBeEqualTo expected + assertEquals(expected, privacySettings) } @Test @@ -701,7 +701,7 @@ internal class DomainMappingTest { warningCode = searchWarningDto.warning_code, warningDescription = searchWarningDto.warning_description, ) - searchWarning shouldBeEqualTo expected + assertEquals(expected, searchWarning) } @Test @@ -762,7 +762,7 @@ internal class DomainMappingTest { }, extraData = downstreamThreadDto.extraData, ) - thread shouldBeEqualTo expected + assertEquals(expected, thread) } @Test @@ -786,7 +786,7 @@ internal class DomainMappingTest { updatedAt = downstreamThreadInfoDto.updated_at, extraData = downstreamThreadInfoDto.extraData, ) - threadInfo shouldBeEqualTo expected + assertEquals(expected, threadInfo) } @Test @@ -802,7 +802,7 @@ internal class DomainMappingTest { blockedAt = downstreamUserBlockDto.created_at, ), ) - blocklist shouldBeEqualTo expected + assertEquals(expected, blocklist) } @Test @@ -815,7 +815,7 @@ internal class DomainMappingTest { userId = blockUserResponse.blocked_user_id, blockedAt = blockUserResponse.created_at, ) - userBlock shouldBeEqualTo expected + assertEquals(expected, userBlock) } @Test @@ -832,7 +832,7 @@ internal class DomainMappingTest { createdAt = downstreamReminderDto.created_at, updatedAt = downstreamReminderDto.updated_at, ) - messageReminder shouldBeEqualTo expected + assertEquals(expected, messageReminder) } @Test @@ -844,7 +844,7 @@ internal class DomainMappingTest { reminders = input.reminders.map { with(sut) { it.toDomain() } }, next = input.next, ) - result shouldBeEqualTo expected + assertEquals(expected, result) } @Test @@ -884,7 +884,7 @@ internal class DomainMappingTest { ) }, ) - result shouldBeEqualTo expected + assertEquals(expected, result) } internal class Fixture { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt index c73d672ac5d..ab8a6547c31 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/EventMappingTestArguments.kt @@ -45,6 +45,7 @@ import io.getstream.chat.android.client.api2.model.dto.MemberAddedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberRemovedEventDto import io.getstream.chat.android.client.api2.model.dto.MemberUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.MessageDeletedEventDto +import io.getstream.chat.android.client.api2.model.dto.MessageDeliveredEventDto import io.getstream.chat.android.client.api2.model.dto.MessageReadEventDto import io.getstream.chat.android.client.api2.model.dto.MessageUpdatedEventDto import io.getstream.chat.android.client.api2.model.dto.NewMessageEventDto @@ -111,6 +112,7 @@ import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent import io.getstream.chat.android.client.events.MemberUpdatedEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -197,6 +199,7 @@ internal object EventMappingTestArguments { private val MEMBER = Mother.randomDownstreamMemberDto() private val HARD_DELETE = randomBoolean() private val FIRST_UNREAD_MESSAGE_ID = randomString() + private val LAST_DELIVERED_MESSAGE_ID = randomString() private val LAST_READ_MESSAGE_ID = randomString() private val UNREAD_MESSAGES = positiveRandomInt() private val TOTAL_UNREAD_COUNT = positiveRandomInt() @@ -420,6 +423,17 @@ internal object EventMappingTestArguments { deleted_for_me = DELETED_FOR_ME, ) + private val messageDeliveredDto = MessageDeliveredEventDto( + type = EventType.MESSAGE_DELIVERED, + created_at = EXACT_DATE, + user = USER, + cid = CID, + channel_type = CHANNEL_TYPE, + channel_id = CHANNEL_ID, + last_delivered_at = EXACT_DATE, + last_delivered_message_id = LAST_DELIVERED_MESSAGE_ID, + ) + private val messageReadDto = MessageReadEventDto( type = EventType.MESSAGE_READ, created_at = EXACT_DATE, @@ -1047,6 +1061,18 @@ internal object EventMappingTestArguments { deletedForMe = messageDeletedDto.deleted_for_me ?: false, ) + private val messageDelivered = MessageDeliveredEvent( + type = EventType.MESSAGE_DELIVERED, + createdAt = EXACT_DATE.date, + rawCreatedAt = EXACT_DATE.rawDate, + user = with(domainMapping) { USER.toDomain() }, + cid = CID, + channelType = CHANNEL_TYPE, + channelId = CHANNEL_ID, + lastDeliveredAt = EXACT_DATE.date, + lastDeliveredMessageId = LAST_DELIVERED_MESSAGE_ID, + ) + private val messageRead = MessageReadEvent( type = messageReadDto.type, createdAt = messageReadDto.created_at.date, @@ -1541,6 +1567,7 @@ internal object EventMappingTestArguments { Arguments.of(memberRemovedDto, memberRemoved), Arguments.of(memberUpdatedDto, memberUpdated), Arguments.of(messageDeletedDto, messageDeleted), + Arguments.of(messageDeliveredDto, messageDelivered), Arguments.of(messageReadDto, messageRead), Arguments.of(messageUpdatedDto, messageUpdated), Arguments.of(notificationAddedToChannelDto, notificationAddedToChannel), diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 02aaec1b8bf..6cbec0cc98f 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -643,15 +643,20 @@ public abstract interface class io/getstream/chat/android/models/ChannelTransfor } public final class io/getstream/chat/android/models/ChannelUserRead : io/getstream/chat/android/models/UserEntity { - public fun (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;)V + public fun (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)V + public synthetic fun (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/User; public final fun component2 ()Ljava/util/Date; public final fun component3 ()I public final fun component4 ()Ljava/util/Date; public final fun component5 ()Ljava/lang/String; - public final fun copy (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/models/ChannelUserRead; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/ChannelUserRead;Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/ChannelUserRead; + public final fun component6 ()Ljava/util/Date; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;)Lio/getstream/chat/android/models/ChannelUserRead; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/ChannelUserRead;Lio/getstream/chat/android/models/User;Ljava/util/Date;ILjava/util/Date;Ljava/lang/String;Ljava/util/Date;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/ChannelUserRead; public fun equals (Ljava/lang/Object;)Z + public final fun getLastDeliveredAt ()Ljava/util/Date; + public final fun getLastDeliveredMessageId ()Ljava/lang/String; public final fun getLastRead ()Ljava/util/Date; public final fun getLastReadMessageId ()Ljava/lang/String; public final fun getLastReceivedEventDate ()Ljava/util/Date; @@ -917,6 +922,7 @@ public final class io/getstream/chat/android/models/EventType { public static final field MEMBER_REMOVED Ljava/lang/String; public static final field MEMBER_UPDATED Ljava/lang/String; public static final field MESSAGE_DELETED Ljava/lang/String; + public static final field MESSAGE_DELIVERED Ljava/lang/String; public static final field MESSAGE_NEW Ljava/lang/String; public static final field MESSAGE_READ Ljava/lang/String; public static final field MESSAGE_UPDATED Ljava/lang/String; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt index a47d8ba8b16..dcd3b38399e 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelUserRead.kt @@ -20,13 +20,17 @@ import androidx.compose.runtime.Immutable import java.util.Date /** - * Information about how many messages are unread in the channel by a given user. + * Represents a user's last read and delivered status in a channel. + * Contains information about how many messages have not been read, + * the last read information, and the last delivered information. * * @property user The user which has read some of the messages and may have some unread messages. * @property lastReceivedEventDate The time of the event that updated this [ChannelUserRead] object. * @property lastRead The time of the last read message. * @property unreadMessages How many messages are unread. * @property lastReadMessageId The ID of the last read message. + * @property lastDeliveredAt The time of the last delivered message. + * @property lastDeliveredMessageId The ID of the last delivered message. */ @Immutable public data class ChannelUserRead( @@ -35,4 +39,6 @@ public data class ChannelUserRead( val unreadMessages: Int, val lastRead: Date, val lastReadMessageId: String?, + val lastDeliveredAt: Date? = null, + val lastDeliveredMessageId: String? = null, ) : UserEntity diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt index 50493d47378..8451d1d0ed1 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/EventType.kt @@ -40,6 +40,7 @@ public object EventType { public const val MESSAGE_UPDATED: String = "message.updated" public const val MESSAGE_DELETED: String = "message.deleted" public const val MESSAGE_READ: String = "message.read" + public const val MESSAGE_DELIVERED: String = "message.delivered" public const val REACTION_NEW: String = "reaction.new" public const val REACTION_DELETED: String = "reaction.deleted" public const val REACTION_UPDATED: String = "reaction.updated" diff --git a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt index 65c607760f7..ad9f5e5b9b4 100644 --- a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt +++ b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt @@ -95,6 +95,7 @@ public fun randomString(size: Int = 20): String = buildString(capacity = size) { append(charPool.random()) } } +public fun randomStringOrNull(): String? = randomString().takeIf { randomBoolean() } public fun randomCID(): String = "${randomString()}:${randomString()}" public fun randomFile(extension: String = randomString(3)): File { @@ -473,13 +474,17 @@ public fun randomChannelUserRead( lastReceivedEventDate: Date = randomDate(), unreadMessages: Int = positiveRandomInt(), lastRead: Date = randomDate(), - lastReadMessageId: String? = randomString(), + lastReadMessageId: String? = randomStringOrNull(), + lastDeliveredAt: Date? = randomDateOrNull(), + lastDeliveredMessageId: String? = randomStringOrNull(), ): ChannelUserRead = ChannelUserRead( user = user, lastReceivedEventDate = lastReceivedEventDate, unreadMessages = unreadMessages, lastRead = lastRead, lastReadMessageId = lastReadMessageId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, ) public suspend fun suspendableRandomMessageList( diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadEntity.kt index 5777364e35e..7141fe0422c 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadEntity.kt @@ -29,4 +29,6 @@ internal data class ChannelUserReadEntity( val unreadMessages: Int, val lastRead: Date, val lastReadMessageId: String?, + val lastDeliveredAt: Date?, + val lastDeliveredMessageId: String?, ) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadMapper.kt index a47d2319c18..f4b06646daa 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadMapper.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channel/userread/internal/ChannelUserReadMapper.kt @@ -20,7 +20,23 @@ import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.User internal fun ChannelUserRead.toEntity(): ChannelUserReadEntity = - ChannelUserReadEntity(getUserId(), lastReceivedEventDate, unreadMessages, lastRead, lastReadMessageId) + ChannelUserReadEntity( + userId = getUserId(), + lastReceivedEventDate = lastReceivedEventDate, + unreadMessages = unreadMessages, + lastRead = lastRead, + lastReadMessageId = lastReadMessageId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, + ) internal suspend fun ChannelUserReadEntity.toModel(getUser: suspend (userId: String) -> User): ChannelUserRead = - ChannelUserRead(getUser(userId), lastReceivedEventDate, unreadMessages, lastRead, lastReadMessageId) + ChannelUserRead( + user = getUser(userId), + lastReceivedEventDate = lastReceivedEventDate, + unreadMessages = unreadMessages, + lastRead = lastRead, + lastReadMessageId = lastReadMessageId, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, + ) diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/MapConverterTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/MapConverterTest.kt index d1bb2a52ecf..c9f9d024497 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/MapConverterTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/database/converter/MapConverterTest.kt @@ -39,7 +39,17 @@ internal class MapConverterTest { @Test fun testEncoding() { val converter = MapConverter() - val readMap = mutableMapOf(data.user1.id to ChannelUserReadEntity(data.user1.id, Date(), 0, Date(), null)) + val readMap = mutableMapOf( + data.user1.id to ChannelUserReadEntity( + userId = data.user1.id, + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = Date(), + lastReadMessageId = null, + lastDeliveredAt = null, + lastDeliveredMessageId = null, + ), + ) val output = converter.readMapToString(readMap) val converted = converter.stringToReadMap(output) converted shouldBeEqualTo readMap diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt index 803cd9d379d..5a3778ee265 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtils.kt @@ -19,9 +19,11 @@ package io.getstream.chat.android.state.event.handler.internal.utils import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.ConnectedEvent import io.getstream.chat.android.client.events.MarkAllReadEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.NotificationMarkReadEvent import io.getstream.chat.android.client.events.NotificationMarkUnreadEvent +import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.models.ChannelUserRead internal val ChatEvent.realType get() = when (this) { @@ -38,6 +40,18 @@ internal fun MessageReadEvent.toChannelUserRead() = ChannelUserRead( // TODO: Backend should send us the last read message id lastReadMessageId = null, ) + +internal fun MessageDeliveredEvent.toChannelUserRead() = ChannelUserRead( + user = user, + lastReceivedEventDate = createdAt, + lastDeliveredAt = lastDeliveredAt, + lastDeliveredMessageId = lastDeliveredMessageId, + // The following fields are not applicable for delivered events + lastRead = NEVER, + unreadMessages = 0, + lastReadMessageId = null, +) + internal fun NotificationMarkReadEvent.toChannelUserRead() = ChannelUserRead( user = user, lastReceivedEventDate = createdAt, diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt index 7fac4503c69..b74eb4dd63f 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelLogic.kt @@ -50,6 +50,7 @@ import io.getstream.chat.android.client.events.MemberAddedEvent import io.getstream.chat.android.client.events.MemberRemovedEvent import io.getstream.chat.android.client.events.MemberUpdatedEvent import io.getstream.chat.android.client.events.MessageDeletedEvent +import io.getstream.chat.android.client.events.MessageDeliveredEvent import io.getstream.chat.android.client.events.MessageReadEvent import io.getstream.chat.android.client.events.MessageUpdatedEvent import io.getstream.chat.android.client.events.NewMessageEvent @@ -638,6 +639,8 @@ internal class ChannelLogic( channelStateLogic.updateRead(event.toChannelUserRead()) } + is MessageDeliveredEvent -> channelStateLogic.updateDelivered(event.toChannelUserRead()) + is NotificationMarkReadEvent -> if (event.thread == null) { channelStateLogic.updateRead(event.toChannelUserRead()) } diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt index 3661cd71500..65dc7b9150d 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogic.kt @@ -205,6 +205,15 @@ internal class ChannelStateLogic( */ fun updateRead(read: ChannelUserRead) = updateReads(listOf(read)) + /** + * Updates the delivered information of this channel. + * + * @param read the information about the delivered message. + */ + fun updateDelivered(read: ChannelUserRead) { + mutableState.upsertDelivered(read) + } + /** * Updates the list of typing users. * The method is responsible for adding/removing typing users, sorting the list and updating both diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt index b1da72f6ec2..db0b1acd7e6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessagesState import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.User +import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState.Companion.LIMIT_MULTIPLIER import io.getstream.chat.android.state.utils.internal.combineStates import io.getstream.chat.android.state.utils.internal.mapState import io.getstream.log.taggedLogger @@ -557,6 +558,23 @@ internal class ChannelMutableState( } } + /** + * Upsert the delivered status for a specific user's read. + */ + fun upsertDelivered(read: ChannelUserRead) { + val updatedRead = rawReads.value[read.user.id]?.copy( + // Update only relevant fields + user = read.user, + lastReceivedEventDate = read.lastReceivedEventDate, + lastDeliveredAt = read.lastDeliveredAt, + lastDeliveredMessageId = read.lastDeliveredMessageId, + ) ?: read + + _rawReads?.apply { + value = value + mapOf(read.user.id to updatedRead) + } + } + /** * Marks channel as read locally if different conditions are met: * 1. Channel has read events enabled @@ -574,7 +592,7 @@ internal class ChannelMutableState( else -> currentUserRead .takeIf { it.lastReadMessageId != lastMessage.id } - ?. let { + ?.let { upsertReads( listOf( it.copy( diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtilsTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtilsTest.kt new file mode 100644 index 00000000000..be043c07476 --- /dev/null +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/event/handler/internal/utils/ChatEventUtilsTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.state.event.handler.internal.utils + +import io.getstream.chat.android.client.extensions.internal.NEVER +import io.getstream.chat.android.client.test.randomMessageDeliveredEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ChatEventUtilsTest { + + @Test + fun `MessageDeliveredEvent toChannelUserRead should create correct ChannelUserRead`() { + val event = randomMessageDeliveredEvent() + + val actual = event.toChannelUserRead() + + assertEquals(event.user, actual.user) + assertEquals(event.createdAt, actual.lastReceivedEventDate) + assertEquals(event.lastDeliveredAt, actual.lastDeliveredAt) + assertEquals(event.lastDeliveredMessageId, actual.lastDeliveredMessageId) + assertEquals(NEVER, actual.lastRead) + assertEquals(0, actual.unreadMessages) + assertEquals(null, actual.lastReadMessageId) + } +} diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt index fd54574b282..0271727763e 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/logic/channel/internal/ChannelStateLogicTest.kt @@ -526,6 +526,15 @@ internal class ChannelStateLogicTest { verify(mutableState, times(2)).setMuted(false) } + @Test + fun `Given updateDelivered is called, Then mutable state is upserted`() { + val read = randomChannelUserRead() + + channelStateLogic.updateDelivered(read) + + verify(mutableState).upsertDelivered(read) + } + companion object { @JvmField diff --git a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableStateTests.kt b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableStateTests.kt index f0859310c74..f4870a212c7 100644 --- a/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableStateTests.kt +++ b/stream-chat-android-state/src/test/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableStateTests.kt @@ -18,14 +18,18 @@ package io.getstream.chat.android.state.plugin.state.channel.internal import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomChannelUserRead import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString +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.advanceTimeBy import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -559,6 +563,50 @@ internal class ChannelMutableStateTests { userMessages.map { it.id } shouldBeEqualTo listOf("msg1", "msg2", "msg3") } + @Test + fun `updateDelivered should update relevant delivered info of user's read`() = runTest { + val user = randomUser() + + // Initial read state for the user + val initialRead = randomChannelUserRead(user) + channelState.upsertReads(listOf(initialRead)) + + // Pre-condition check + var userRead = channelState.reads.value.find { it.user.id == user.id } + assertEquals(initialRead, userRead) + + // Update delivered info + val delivered = randomChannelUserRead(user.copy(name = randomString())) + channelState.upsertDelivered(delivered) + + // Post-condition check + userRead = channelState.reads.value.find { it.user.id == user.id } + assertNotNull(userRead) + assertEquals(delivered.user, userRead!!.user) + assertEquals(delivered.lastReceivedEventDate, userRead.lastReceivedEventDate) + assertEquals(delivered.lastDeliveredAt, userRead.lastDeliveredAt) + assertEquals(delivered.lastDeliveredMessageId, userRead.lastDeliveredMessageId) + assertEquals(initialRead.unreadMessages, userRead.unreadMessages) + assertEquals(initialRead.lastRead, userRead.lastRead) + assertEquals(initialRead.lastReadMessageId, userRead.lastReadMessageId) + } + + @Test + fun `updateDelivered should not create new read if user read does not exist`() = runTest { + val user = randomUser() + + // Ensure no initial read state for the user + channelState.upsertReads(emptyList()) + + // Update delivered info + val delivered = randomChannelUserRead(user) + channelState.upsertDelivered(delivered) + + // Post-condition check + val userRead = channelState.reads.value.find { it.user.id == user.id } + assertEquals(delivered, userRead) + } + private fun ChannelMutableState.assertPinnedMessagesSizeEqualsTo(size: Int) { require(pinnedMessages.value.size == size) { "pinnedMessages should have $size items, but was ${pinnedMessages.value.size}" From a34b888e62950bbf2bacb0e976f0e2fc278bd374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Oct 2025 10:40:21 +0000 Subject: [PATCH 14/58] Refactor: Add default empty implementations for `QueryChannelsListener` interface instead --- .../api/stream-chat-android-client.api | 15 ++++++--------- .../chat/android/client/plugin/Plugin.kt | 15 --------------- .../plugin/listeners/QueryChannelsListener.kt | 12 +++++++----- 3 files changed, 13 insertions(+), 29 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 2c949329278..078d166dbe7 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -3107,12 +3107,6 @@ public abstract interface class io/getstream/chat/android/client/plugin/Plugin : public static synthetic fun onQueryChannelRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/api/models/QueryChannelRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryChannelResult (Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/api/models/QueryChannelRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryChannelResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Ljava/lang/String;Ljava/lang/String;Lio/getstream/chat/android/client/api/models/QueryChannelRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun onQueryChannelsPrecondition (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun onQueryChannelsPrecondition$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun onQueryChannelsRequest (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun onQueryChannelsRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public fun onQueryChannelsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun onQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryDraftMessagesResult (Lio/getstream/result/Result;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onQueryDraftMessagesResult (Lio/getstream/result/Result;Ljava/lang/Integer;Ljava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun onQueryDraftMessagesResult$suspendImpl (Lio/getstream/chat/android/client/plugin/Plugin;Lio/getstream/result/Result;Lio/getstream/chat/android/models/FilterObject;ILjava/lang/String;Lio/getstream/chat/android/models/querysort/QuerySorter;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -3249,9 +3243,12 @@ public abstract interface class io/getstream/chat/android/client/plugin/listener } public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener { - public abstract fun onQueryChannelsPrecondition (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun onQueryChannelsRequest (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun onQueryChannelsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryChannelsPrecondition (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryChannelsPrecondition$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryChannelsRequest (Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryChannelsRequest$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun onQueryChannelsResult (Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun onQueryChannelsResult$suspendImpl (Lio/getstream/chat/android/client/plugin/listeners/QueryChannelsListener;Lio/getstream/result/Result;Lio/getstream/chat/android/client/api/models/QueryChannelsRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public abstract interface class io/getstream/chat/android/client/plugin/listeners/QueryMembersListener { 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 7efef402730..5551fd067c3 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 @@ -18,7 +18,6 @@ package io.getstream.chat.android.client.plugin import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.api.models.QueryChannelRequest -import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.api.models.QueryThreadsRequest import io.getstream.chat.android.client.errorhandler.ErrorHandler import io.getstream.chat.android.client.events.ChatEvent @@ -274,20 +273,6 @@ public interface Plugin : /* No-Op */ } - override suspend fun onQueryChannelsPrecondition(request: QueryChannelsRequest): Result = - Result.Success(Unit) - - override suspend fun onQueryChannelsRequest(request: QueryChannelsRequest) { - /* No-Op */ - } - - override suspend fun onQueryChannelsResult( - result: Result>, - request: QueryChannelsRequest, - ) { - /* No-Op */ - } - override fun onTypingEventPrecondition( eventType: String, channelType: String, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt index b578cdff4c3..2ff5e7f7aef 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/listeners/QueryChannelsListener.kt @@ -35,19 +35,21 @@ public interface QueryChannelsListener { * * @return [Result.Success] if precondition passes otherwise [Result.Failure] */ - public suspend fun onQueryChannelsPrecondition( - request: QueryChannelsRequest, - ): Result + public suspend fun onQueryChannelsPrecondition(request: QueryChannelsRequest): Result = + Result.Success(Unit) /** * Runs side effect before the request is launched. * * @param request [QueryChannelsRequest] which is going to be used for the request. */ - public suspend fun onQueryChannelsRequest(request: QueryChannelsRequest) + public suspend fun onQueryChannelsRequest(request: QueryChannelsRequest) { /* No-Op */ } /** * Runs this function on the [Result] of this [QueryChannelsRequest]. */ - public suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) + public suspend fun onQueryChannelsResult( + result: Result>, + request: QueryChannelsRequest, + ) { /* No-Op */ } } From 747fd8f7a222becc69a9dcfd5bc8070dfde03027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Oct 2025 10:41:10 +0000 Subject: [PATCH 15/58] Deprecate `hasUnread` in favor of `currentUserUnreadCount` --- .../main/java/io/getstream/chat/android/models/Channel.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt index 0f9490860c1..4b3c27ce495 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Channel.kt @@ -132,6 +132,14 @@ public data class Channel( /** * Whether a channel contains unread messages or not. */ + @Deprecated( + message = "Use the extension property Channel.currentUserUnreadCount instead and check if it's greater than 0", + replaceWith = ReplaceWith( + expression = "currentUserUnreadCount", + imports = ["io.getstream.chat.android.client.extensions.currentUserUnreadCount"], + ), + level = DeprecationLevel.WARNING, + ) val hasUnread: Boolean get() = unreadCount > 0 From 4c8facfc2a8293c92f0a0bc908a0c592863e7e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Oct 2025 15:06:50 +0000 Subject: [PATCH 16/58] Add `userRead` and `deliveredReads` helper functions Extracted the logic for retrieving a user's read state into a new `Channel.userRead()` helper function. This simplifies the implementation of `countUnreadMentionsForUser` and `currentUserUnreadCount`. Additionally, introduced `Channel.deliveredReads(message)` to get a list of users who have received a specific message. --- .../api/stream-chat-android-client.api | 2 ++ .../client/extensions/ChannelExtension.kt | 28 +++++++++++++++++-- .../client/extensions/internal/Channel.kt | 3 +- 3 files changed, 30 insertions(+), 3 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 078d166dbe7..e8f494bc92a 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -2659,12 +2659,14 @@ public final class io/getstream/chat/android/client/extensions/ChannelExtensionK public static final fun countUnreadMentionsForUser (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)I public static final fun currentUserUnreadCount (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)I public static synthetic fun currentUserUnreadCount$default (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;ILjava/lang/Object;)I + public static final fun deliveredReads (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Ljava/util/List; public static final fun isAnonymousChannel (Lio/getstream/chat/android/models/Channel;)Z public static final fun isArchive (Lio/getstream/chat/android/models/Channel;)Z public static final fun isMutedFor (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)Z public static final fun isPinned (Lio/getstream/chat/android/models/Channel;)Z public static final fun syncUnreadCountWithReads (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Lio/getstream/chat/android/models/Channel; public static synthetic fun syncUnreadCountWithReads$default (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/Channel; + public static final fun userRead (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Lio/getstream/chat/android/models/ChannelUserRead; } public final class io/getstream/chat/android/client/extensions/FlowExtensions { diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt index 386ca4852a7..99089736982 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt @@ -17,11 +17,14 @@ package io.getstream.chat.android.client.extensions import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.extensions.internal.containsUserMention import io.getstream.chat.android.client.extensions.internal.wasCreatedAfter import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Member +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.models.UserId @@ -85,7 +88,7 @@ public fun Channel.getMembersExcludingCurrent( * @return Number of messages containing unread user mention. */ public fun Channel.countUnreadMentionsForUser(user: User): Int { - val lastMessageSeenDate = read.firstOrNull { read -> read.user.id == user.id }?.lastRead + val lastMessageSeenDate = userRead(user.id)?.lastRead val messagesToCheck = if (lastMessageSeenDate == null) { messages @@ -103,7 +106,7 @@ public fun Channel.countUnreadMentionsForUser(user: User): Int { */ public fun Channel.currentUserUnreadCount( currentUserId: UserId? = ChatClient.instance().getCurrentUser()?.id, -): Int = read.firstOrNull { it.user.id == currentUserId }?.unreadMessages ?: 0 +): Int = currentUserId?.let(::userRead)?.unreadMessages ?: 0 /** * Synchronizes the unread count of the channel with the read state of the current user. @@ -117,3 +120,24 @@ public fun Channel.syncUnreadCountWithReads( currentUserId: UserId? = ChatClient.instance().getCurrentUser()?.id, ): Channel = copy(unreadCount = currentUserUnreadCount(currentUserId)) + +/** + * Returns the user's read state for this channel. + * + * @param userId The ID of the user whose read state is to be retrieved. + * @return The [ChannelUserRead] object representing the user's read state, or null if not found. + */ +public fun Channel.userRead(userId: UserId): ChannelUserRead? = + read.firstOrNull { read -> read.user.id == userId } + +/** + * Returns a list of [ChannelUserRead] objects representing users who have read the given [message]. + * + * @param message The [Message] object for which to find delivered reads. + * @return A list of [ChannelUserRead] objects for users who have read the message + */ +public fun Channel.deliveredReads(message: Message): List = + read.filter { read -> + (read.lastDeliveredAt ?: NEVER) > message.getCreatedAtOrThrow() && + read.user.id != message.user.id + } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt index 6ac6e41be16..49482a506f4 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/internal/Channel.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.extensions.internal import io.getstream.chat.android.client.extensions.syncUnreadCountWithReads +import io.getstream.chat.android.client.extensions.userRead import io.getstream.chat.android.client.query.pagination.AnyChannelPaginationRequest import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.core.internal.InternalStreamChatApi @@ -216,7 +217,7 @@ public fun Channel.removeMembership(currentUserId: String?): Channel = */ @InternalStreamChatApi public fun Channel.updateReads(newRead: ChannelUserRead, currentUserId: UserId): Channel { - val oldRead = read.firstOrNull { it.user.id == newRead.user.id } + val oldRead = userRead(newRead.user.id) return copy( read = if (oldRead != null) { read - oldRead + newRead From e887dcbf3308a647767e5a5dd63e6284dbe9f44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Oct 2025 15:11:45 +0000 Subject: [PATCH 17/58] Introduce `markChannelsAsDelivered`, a new function to mark the last message in a list of channels as delivered. It contains logic to identify the appropriate message to mark as delivered, considering factors like whether the message is already read or delivered. --- .../client/receipts/MessageReceiptManager.kt | 58 ++++++- .../receipts/MessageReceiptManagerTest.kt | 161 +++++++++++++++++- 2 files changed, 210 insertions(+), 9 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index 5faeaf23b64..625ec09dac3 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -16,8 +16,13 @@ package io.getstream.chat.android.client.receipts +import io.getstream.chat.android.client.extensions.getCreatedAtOrThrow +import io.getstream.chat.android.client.extensions.internal.NEVER +import io.getstream.chat.android.client.extensions.internal.lastMessage +import io.getstream.chat.android.client.extensions.userRead import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.client.utils.message.isDeleted +import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.User @@ -38,13 +43,42 @@ internal class MessageReceiptManager( private val messageReceiptRepository: MessageReceiptRepository, ) { - private val logger by taggedLogger("MessageReceiptManager") + private val logger by taggedLogger("Chat:MessageReceiptManager") + + /** + * Request to mark the last undelivered messages in the given channels as delivered. + * + * A delivery message candidate is the last non-deleted message in the channel that: + * + * - Is not yet marked as read by the current user + * - Is not yet marked as delivered by the current user + */ + fun markChannelsAsDelivered(channels: List) { + val deliveredMessageCandidates = channels.mapNotNull(::getUndeliveredMessage) + markMessagesAsDelivered(messages = deliveredMessageCandidates) + } + /** + * Request to mark the given messages as delivered if delivery receipts are enabled + * in the current user privacy settings. + * + * A message can be marked as delivered only if: + * + * - It was not sent by the current user + * - It is not a system message + * - It is not deleted + */ fun markMessagesAsDelivered(messages: List) { - logger.d { "[markMessagesAsDelivered] Preparing delivery receipts for ${messages.size} messages…" } + if (messages.isEmpty()) { + logger.w { "[markMessagesAsDelivered] No receipts to send" } + return + } + + logger.d { "[markMessagesAsDelivered] Processing delivery receipts for ${messages.size} messages…" } - val currentUser = requireNotNull(getCurrentUser()) { - "Cannot send delivery receipts: current user is null" + val currentUser = getCurrentUser() ?: run { + logger.w { "[markMessagesAsDelivered] Cannot send delivery receipts: current user is null" } + return } // Check if delivery receipts are enabled for the current user @@ -77,6 +111,22 @@ internal class MessageReceiptManager( } } + private fun getUndeliveredMessage(channel: Channel): Message? { + val currentUser = getCurrentUser() ?: run { + logger.w { "[getUndeliveredMessage] Cannot get undelivered message: current user is null" } + return null + } + val userRead = channel.userRead(currentUser.id) ?: return null + // Get the last non-deleted message in the channel + val lastMessage = channel.lastMessage ?: return null + val createdAt = lastMessage.getCreatedAtOrThrow() + // Check if the last message is already marked as read + if (createdAt <= userRead.lastRead) return null + // Check if the last message is already marked as delivered + if (createdAt <= (userRead.lastDeliveredAt ?: NEVER)) return null + return lastMessage + } + private fun shouldSendDeliveryReceipt(currentUserId: UserId, message: Message): Boolean { // Don't send delivery receipts for messages sent by the current user if (message.user.id == currentUserId) { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index 8154e803c2c..46f528a28d6 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -18,9 +18,12 @@ package io.getstream.chat.android.client.receipts import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings +import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User +import io.getstream.chat.android.randomChannel +import io.getstream.chat.android.randomChannelUserRead import io.getstream.chat.android.randomDate import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomMessageList @@ -29,7 +32,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test -import org.junit.jupiter.api.assertThrows import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -64,14 +66,14 @@ internal class MessageReceiptManagerTest { } @Test - fun `should not store message delivery receipts when current user is null`() = runTest { + fun `should skip storing message delivery receipts when current user is null`() = runTest { val messages = randomMessageList(10) { randomMessage() } val fixture = Fixture().givenCurrentUser(user = null) val sut = fixture.get() - assertThrows(message = "Cannot send delivery receipts: current user is null") { - sut.markMessagesAsDelivered(messages) - } + sut.markMessagesAsDelivered(messages) + + fixture.verifyUpsertNotCalled() } @Test @@ -154,6 +156,155 @@ internal class MessageReceiptManagerTest { fixture.verifyUpsertNotCalled() } + @Test + fun `store channel delivery receipts success`() = runTest { + val deliveredMessage = randomMessage( + createdAt = Now, + deletedAt = null, + deletedForMe = false, + ) + val channel = randomChannel( + messages = listOf( + deliveredMessage, + randomMessage(user = CurrentUser, createdAt = NEVER), + randomMessage(type = "system", createdAt = NEVER), + randomMessage(deletedAt = randomDate(), createdAt = NEVER), + ), + read = listOf( + randomChannelUserRead( + user = CurrentUser, + lastRead = NEVER, + lastDeliveredAt = null, + ), + ), + ) + val channels = listOf(channel) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels) + + val receipts = listOf( + MessageReceipt( + messageId = deliveredMessage.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = deliveredMessage.cid, + ), + ) + fixture.verifyUpsertMessageReceiptsCalled(receipts) + } + + @Test + fun `should skip storing channel delivery receipts when current user is null`() = runTest { + val channel = randomChannel() + val fixture = Fixture().givenCurrentUser(user = null) + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertNotCalled() + } + + @Test + fun `should skip storing channel delivery receipts when user read is not found`() = runTest { + val channel = randomChannel( + messages = randomMessageList(10), + read = listOf(randomChannelUserRead()), + ) + val channels = listOf(channel) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels) + + fixture.verifyUpsertNotCalled() + } + + @Test + fun `should skip storing channel delivery receipts when last message is not found`() = runTest { + val channel = randomChannel( + messages = emptyList(), + read = listOf(randomChannelUserRead(user = CurrentUser)), + ) + val channels = listOf(channel) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels) + + fixture.verifyUpsertNotCalled() + } + + @Test + fun `should skip storing channel delivery receipts when last non-deleted message is not found`() = runTest { + val channel = randomChannel( + messages = randomMessageList(10) { randomMessage(deletedAt = Now) }, + read = listOf(randomChannelUserRead(user = CurrentUser)), + ) + val channels = listOf(channel) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels) + + fixture.verifyUpsertNotCalled() + } + + @Test + fun `should skip storing channel delivery receipts when last message is already read`() = runTest { + val channel = randomChannel( + messages = listOf( + randomMessage( + createdAt = Now, + deletedAt = null, + deletedForMe = false, + ), + ), + read = listOf( + randomChannelUserRead( + user = CurrentUser, + lastRead = Now, + lastDeliveredAt = null, + ), + ), + ) + val channels = listOf(channel) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels) + + fixture.verifyUpsertNotCalled() + } + + @Test + fun `should skip storing channel delivery receipts when last message is already delivered`() = runTest { + val channel = randomChannel( + messages = listOf( + randomMessage( + createdAt = Now, + deletedAt = null, + deletedForMe = false, + ), + ), + read = listOf( + randomChannelUserRead( + user = CurrentUser, + lastRead = NEVER, + lastDeliveredAt = Now, + ), + ), + ) + val channels = listOf(channel) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels) + + fixture.verifyUpsertNotCalled() + } + private class Fixture { private val mockMessageReceiptRepository = mock() private var getCurrentUser: () -> User? = { CurrentUser } From ee06acd4388bdf21d480412f415ae048cda23a07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 28 Oct 2025 16:06:56 +0000 Subject: [PATCH 18/58] Refactor MessageReceiptReporter to use a polling mechanism Replaced the Flow-based implementation in `MessageReceiptReporter` with a `while(isActive)` loop and a `delay`. This change simplifies the logic for polling and reporting message delivery receipts. The `getAllMessageReceiptsByType` function in `MessageReceiptRepository` and `MessageReceiptDao` is now a `suspend` function instead of returning a `Flow`. The corresponding tests have been updated to reflect these changes. --- .../api/stream-chat-android-client.api | 2 +- .../persistence/db/dao/MessageReceiptDao.kt | 3 +- .../repository/MessageReceiptRepository.kt | 3 +- .../MessageReceiptRepositoryImpl.kt | 8 +-- .../client/receipts/MessageReceiptReporter.kt | 63 +++++++++---------- .../MessageReceiptRepositoryImplTest.kt | 29 ++++----- .../receipts/MessageReceiptReporterTest.kt | 56 +++++++---------- 7 files changed, 67 insertions(+), 97 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 e8f494bc92a..f16f267192b 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -3026,7 +3026,7 @@ public final class io/getstream/chat/android/client/persistence/db/dao/MessageRe public fun deleteAll (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun deleteByMessageIds (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun getRequiredConverters ()Ljava/util/List; - public fun selectAllByType (Ljava/lang/String;I)Lkotlinx/coroutines/flow/Flow; + public fun selectAllByType (Ljava/lang/String;ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun upsert (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt index 2138f8f2c81..1d21d68fdc8 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/db/dao/MessageReceiptDao.kt @@ -21,7 +21,6 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity -import kotlinx.coroutines.flow.Flow @Dao internal interface MessageReceiptDao { @@ -30,7 +29,7 @@ internal interface MessageReceiptDao { suspend fun upsert(receipts: List) @Query("SELECT * FROM message_receipt WHERE type = :type ORDER BY createdAt ASC LIMIT :limit") - fun selectAllByType(type: String, limit: Int): Flow> + suspend fun selectAllByType(type: String, limit: Int): List @Query("DELETE FROM message_receipt WHERE messageId IN (:messageIds)") suspend fun deleteByMessageIds(messageIds: List) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt index ef94d0317f6..cbd013fad9b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepository.kt @@ -18,7 +18,6 @@ package io.getstream.chat.android.client.persistence.repository import io.getstream.chat.android.client.persistence.db.ChatClientDatabase import io.getstream.chat.android.client.receipts.MessageReceipt -import kotlinx.coroutines.flow.Flow internal interface MessageReceiptRepository { @@ -31,7 +30,7 @@ internal interface MessageReceiptRepository { suspend fun upsertMessageReceipts(receipts: List) - fun getAllMessageReceiptsByType(type: String, limit: Int): Flow> + suspend fun getAllMessageReceiptsByType(type: String, limit: Int): List suspend fun deleteMessageReceiptsByMessageIds(messageIds: List) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt index 0bb77cf1a33..f5302815f84 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImpl.kt @@ -19,8 +19,6 @@ package io.getstream.chat.android.client.persistence.repository import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity import io.getstream.chat.android.client.receipts.MessageReceipt -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map internal class MessageReceiptRepositoryImpl( private val dao: MessageReceiptDao, @@ -30,10 +28,8 @@ internal class MessageReceiptRepositoryImpl( dao.upsert(receipts.map(MessageReceipt::toEntity)) } - override fun getAllMessageReceiptsByType(type: String, limit: Int): Flow> = - dao.selectAllByType(type, limit).map { receipts -> - receipts.map(MessageReceiptEntity::toModel) - } + override suspend fun getAllMessageReceiptsByType(type: String, limit: Int): List = + dao.selectAllByType(type, limit).map(MessageReceiptEntity::toModel) override suspend fun deleteMessageReceiptsByMessageIds(messageIds: List) { dao.deleteByMessageIds(messageIds) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt index a90a19dd338..06de19158fc 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt @@ -22,12 +22,9 @@ import io.getstream.chat.android.models.Message import io.getstream.log.taggedLogger import io.getstream.result.onSuccessSuspend import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch /** * Reports message delivery receipts to the server in batches of [MAX_BATCH_SIZE] @@ -39,42 +36,42 @@ internal class MessageReceiptReporter( private val messageReceiptRepository: MessageReceiptRepository, ) { - private val logger by taggedLogger("MessageReceiptReporter") + private val logger by taggedLogger("Chat:MessageReceiptReporter") - @OptIn(FlowPreview::class) fun init() { - logger.d { "[init] Handling message receipts reporting…" } - messageReceiptRepository.getAllMessageReceiptsByType( - type = MessageReceipt.TYPE_DELIVERY, - limit = MAX_BATCH_SIZE, - ) - .sample(REPORT_INTERVAL_IN_MS) - .filterNot(List::isEmpty) - .map { receipts -> - receipts.map { receipt -> + logger.d { "Initializing…" } + scope.launch { + while (isActive) { + val messages = messageReceiptRepository.getAllMessageReceiptsByType( + type = MessageReceipt.TYPE_DELIVERY, + limit = MAX_BATCH_SIZE, + ).map { receipt -> Message( id = receipt.messageId, cid = receipt.cid, ) } - } - .onEach { messages -> - logger.d { "[init] Reporting delivery receipts for ${messages.size} messages…" } - chatClient.markMessagesAsDelivered(messages) - .execute() - .onSuccessSuspend { - logger.d { "[init] Successfully reported delivery receipts for ${messages.size} messages" } - val deliveredMessageIds = messages.map(Message::id) - messageReceiptRepository.deleteMessageReceiptsByMessageIds(deliveredMessageIds) - } - .onError { error -> - logger.e { - "[init] Failed to report delivery receipts for ${messages.size} messages: " + - error.message + + if (messages.isNotEmpty()) { + logger.d { "Reporting delivery receipts for ${messages.size} messages…" } + chatClient.markMessagesAsDelivered(messages) + .execute() + .onSuccessSuspend { + logger.d { "Successfully reported delivery receipts for ${messages.size} messages" } + val deliveredMessageIds = messages.map(Message::id) + messageReceiptRepository.deleteMessageReceiptsByMessageIds(deliveredMessageIds) + } + .onError { error -> + logger.e { + "Failed to report delivery receipts for ${messages.size} messages: " + + error.message + } } - } + } + + delay(REPORT_INTERVAL_IN_MS) } - .launchIn(scope) + } } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt index 4bd02a52d63..225156c41ba 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/MessageReceiptRepositoryImplTest.kt @@ -16,7 +16,6 @@ package io.getstream.chat.android.client.persistence.repository -import app.cash.turbine.test import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao import io.getstream.chat.android.client.persistence.db.entity.MessageReceiptEntity import io.getstream.chat.android.client.randomMessageReceipt @@ -24,7 +23,6 @@ import io.getstream.chat.android.client.randomMessageReceiptEntity import io.getstream.chat.android.client.receipts.MessageReceipt import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomString -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.jupiter.api.Assertions @@ -68,19 +66,17 @@ internal class MessageReceiptRepositoryImplTest { ) val sut = fixture.get() - sut.getAllMessageReceiptsByType(type, limit).test { - val actual = awaitItem() + val actual = sut.getAllMessageReceiptsByType(type, limit) - val expected = listOf( - MessageReceipt( - messageId = receipt.messageId, - type = receipt.type, - createdAt = receipt.createdAt, - cid = receipt.cid, - ), - ) - Assertions.assertEquals(expected, actual) - } + val expected = listOf( + MessageReceipt( + messageId = receipt.messageId, + type = receipt.type, + createdAt = receipt.createdAt, + cid = receipt.cid, + ), + ) + Assertions.assertEquals(expected, actual) } @Test @@ -106,16 +102,13 @@ internal class MessageReceiptRepositoryImplTest { private class Fixture { - private val receiptsStateFlow = MutableStateFlow>(emptyList()) - private val mockDao = mock { onBlocking { upsert(any()) } doReturn Unit onBlocking { deleteByMessageIds(any()) } doReturn Unit } fun givenMessageReceiptsByType(type: String, limit: Int, receipts: List) = apply { - wheneverBlocking { mockDao.selectAllByType(type, limit) } doReturn - receiptsStateFlow.apply { value = receipts } + wheneverBlocking { mockDao.selectAllByType(type, limit) } doReturn receipts } fun verifyUpsertCalled(receipts: List) { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt index 6f75759a6ff..1a25eca74bf 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt @@ -25,7 +25,6 @@ import io.getstream.result.Error import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runTest import org.junit.Test @@ -37,6 +36,7 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import org.mockito.kotlin.wheneverBlocking import org.mockito.verification.VerificationMode @OptIn(ExperimentalCoroutinesApi::class) @@ -60,7 +60,7 @@ internal class MessageReceiptReporterTest { val sut = fixture.get(backgroundScope) sut.init() - advanceTimeBy(1100) // Advance time to after the interval window + advanceTimeBy(100) // Allow initial execution fixture.verifyMarkMessagesAsDeliveredCalled(messages = messages) val messageIds = messages.map(Message::id) @@ -71,19 +71,18 @@ internal class MessageReceiptReporterTest { fun `should not delete receipts when marking messages as delivered fails`() = runTest { val fixture = Fixture() .givenMessageReceipts(listOf(randomMessageReceipt())) + // Simulate an error when marking messages as delivered .givenMarkMessagesAsDelivered(error = mock()) val sut = fixture.get(backgroundScope) sut.init() - advanceTimeBy(1100) // Allow initial execution + advanceTimeBy(100) // Allow initial execution fixture.verifyDeleteByMessageIdsCalled(never()) - // Keep processing subsequent success emissions - fixture.givenMessageReceipts(listOf(randomMessageReceipt())) + // Keep processing in the next time window fixture.givenMarkMessagesAsDelivered() - - advanceTimeBy(1100) + advanceTimeBy(1000) fixture.verifyMarkMessagesAsDeliveredCalled(times(2)) fixture.verifyDeleteByMessageIdsCalled() @@ -96,7 +95,7 @@ internal class MessageReceiptReporterTest { val sut = fixture.get(backgroundScope) sut.init() - advanceTimeBy(1100) + advanceTimeBy(100) // Allow initial execution fixture.verifyMarkMessagesAsDeliveredCalled(never()) fixture.verifyDeleteByMessageIdsCalled(never()) @@ -110,25 +109,15 @@ internal class MessageReceiptReporterTest { val sut = fixture.get(backgroundScope) sut.init() + advanceTimeBy(100) // Allow initial execution - // Trigger multiple emissions - - advanceTimeBy(1100) // Collecting the first emission - - // Trigger a new list - fixture.givenMessageReceipts(listOf(randomMessageReceipt())) - advanceTimeBy(1000) // Wait for delay + advanceTimeBy(1000) // Advance to the second interval - // Trigger a new list - fixture.givenMessageReceipts(listOf(randomMessageReceipt())) - advanceTimeBy(100) // Collecting the second emission + advanceTimeBy(1000) // Advance to the third interval - // Trigger a new list - fixture.givenMessageReceipts(listOf(randomMessageReceipt())) - advanceTimeBy(1000) // Wait for delay - advanceTimeBy(100) // Collecting the third emission + advanceTimeBy(1000) // Advance to the fourth interval - fixture.verifyMarkMessagesAsDeliveredCalled(times(3)) + fixture.verifyMarkMessagesAsDeliveredCalled(times(4)) } @Test @@ -139,14 +128,11 @@ internal class MessageReceiptReporterTest { val sut = fixture.get(backgroundScope) sut.init() - advanceTimeBy(1100) // Allow initial execution + advanceTimeBy(100) // Allow initial execution backgroundScope.cancel() - // Trigger a new list - fixture.givenMessageReceipts(listOf(randomMessageReceipt())) - - advanceTimeBy(2000) // Try to advance time after cancellation + advanceTimeBy(1000) // Try to advance time after cancellation fixture.verifyMarkMessagesAsDeliveredCalled(times(1)) } @@ -154,15 +140,15 @@ internal class MessageReceiptReporterTest { private class Fixture { private val mockChatClient = mock() - private val receiptsStateFlow = MutableStateFlow>(emptyList()) - - private val mockMessageReceiptRepository = mock { - onBlocking { getAllMessageReceiptsByType(type = MessageReceipt.TYPE_DELIVERY, limit = 100) } doReturn - receiptsStateFlow - } + private val mockMessageReceiptRepository = mock() fun givenMessageReceipts(receipts: List) = apply { - receiptsStateFlow.value = receipts + wheneverBlocking { + mockMessageReceiptRepository.getAllMessageReceiptsByType( + type = MessageReceipt.TYPE_DELIVERY, + limit = 100, + ) + } doReturn receipts } fun givenMarkMessagesAsDelivered(messages: List? = null, error: Error? = null) = apply { From 76c9e26f7897fd98451cac02f8be87756fbf2768 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 09:40:02 +0000 Subject: [PATCH 19/58] Moves the user ID update in the `switchUser` function to after the user is disconnected. This ensures that coroutines related to the previous user are properly cancelled before the new user connects. --- .../main/java/io/getstream/chat/android/client/ChatClient.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 60ccfb2c803..b7ae74ea1f9 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 @@ -737,9 +737,11 @@ internal constructor( ): Call { return CoroutineCall(clientScope) { logger.d { "[switchUser] user.id: '${user.id}'" } - userScope.userId.value = user.id notifications.deleteDevice() // always delete device if switching users disconnectUserSuspend(flushPersistence = true) + // change userId only after disconnect, + // otherwise the userScope won't cancel coroutines related to the previous user. + userScope.userId.value = user.id onDisconnectionComplete() connectUserSuspend(user, tokenProvider, timeoutMilliseconds).also { logger.v { "[switchUser] completed('${user.id}')" } From 03461bc22bff1c2e82cffa907bb7504d8434a503 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 09:59:29 +0000 Subject: [PATCH 20/58] Rename MessageReceiptReporter.init to start and add logging --- .../client/receipts/MessageReceiptReporter.kt | 60 ++++++++++--------- .../receipts/MessageReceiptReporterTest.kt | 10 ++-- 2 files changed, 37 insertions(+), 33 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt index 06de19158fc..bf43a83f9c6 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt @@ -38,38 +38,42 @@ internal class MessageReceiptReporter( private val logger by taggedLogger("Chat:MessageReceiptReporter") - fun init() { - logger.d { "Initializing…" } + fun start() { + logger.d { "Starting reporter…" } scope.launch { - while (isActive) { - val messages = messageReceiptRepository.getAllMessageReceiptsByType( - type = MessageReceipt.TYPE_DELIVERY, - limit = MAX_BATCH_SIZE, - ).map { receipt -> - Message( - id = receipt.messageId, - cid = receipt.cid, - ) - } + try { + while (isActive) { + val messages = messageReceiptRepository.getAllMessageReceiptsByType( + type = MessageReceipt.TYPE_DELIVERY, + limit = MAX_BATCH_SIZE, + ).map { receipt -> + Message( + id = receipt.messageId, + cid = receipt.cid, + ) + } - if (messages.isNotEmpty()) { - logger.d { "Reporting delivery receipts for ${messages.size} messages…" } - chatClient.markMessagesAsDelivered(messages) - .execute() - .onSuccessSuspend { - logger.d { "Successfully reported delivery receipts for ${messages.size} messages" } - val deliveredMessageIds = messages.map(Message::id) - messageReceiptRepository.deleteMessageReceiptsByMessageIds(deliveredMessageIds) - } - .onError { error -> - logger.e { - "Failed to report delivery receipts for ${messages.size} messages: " + - error.message + if (messages.isNotEmpty()) { + logger.d { "Reporting delivery receipts for ${messages.size} messages…" } + chatClient.markMessagesAsDelivered(messages) + .execute() + .onSuccessSuspend { + logger.d { "Successfully reported delivery receipts for ${messages.size} messages" } + val deliveredMessageIds = messages.map(Message::id) + messageReceiptRepository.deleteMessageReceiptsByMessageIds(deliveredMessageIds) } - } - } + .onError { error -> + logger.e { + "Failed to report delivery receipts for ${messages.size} messages: " + + error.message + } + } + } - delay(REPORT_INTERVAL_IN_MS) + delay(REPORT_INTERVAL_IN_MS) + } + } finally { + logger.d { "Reporter is no longer active" } } } } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt index 1a25eca74bf..bb07ae4238b 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt @@ -59,7 +59,7 @@ internal class MessageReceiptReporterTest { .givenMarkMessagesAsDelivered(messages) val sut = fixture.get(backgroundScope) - sut.init() + sut.start() advanceTimeBy(100) // Allow initial execution fixture.verifyMarkMessagesAsDeliveredCalled(messages = messages) @@ -75,7 +75,7 @@ internal class MessageReceiptReporterTest { .givenMarkMessagesAsDelivered(error = mock()) val sut = fixture.get(backgroundScope) - sut.init() + sut.start() advanceTimeBy(100) // Allow initial execution fixture.verifyDeleteByMessageIdsCalled(never()) @@ -94,7 +94,7 @@ internal class MessageReceiptReporterTest { .givenMessageReceipts(receipts = emptyList()) val sut = fixture.get(backgroundScope) - sut.init() + sut.start() advanceTimeBy(100) // Allow initial execution fixture.verifyMarkMessagesAsDeliveredCalled(never()) @@ -108,7 +108,7 @@ internal class MessageReceiptReporterTest { .givenMarkMessagesAsDelivered() val sut = fixture.get(backgroundScope) - sut.init() + sut.start() advanceTimeBy(100) // Allow initial execution advanceTimeBy(1000) // Advance to the second interval @@ -127,7 +127,7 @@ internal class MessageReceiptReporterTest { .givenMarkMessagesAsDelivered() val sut = fixture.get(backgroundScope) - sut.init() + sut.start() advanceTimeBy(100) // Allow initial execution backgroundScope.cancel() From cf4565d977148f7b22368efefa3d8d262269d16f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 10:19:31 +0000 Subject: [PATCH 21/58] Refactor MessageReceiptManagerTest to standardize verification methods for upsertMessageReceipts calls --- .../receipts/MessageReceiptManagerTest.kt | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index 46f528a28d6..c44d98127dd 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -35,7 +35,9 @@ import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.mock import org.mockito.kotlin.never +import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking +import org.mockito.verification.VerificationMode import java.util.Date internal class MessageReceiptManagerTest { @@ -62,7 +64,7 @@ internal class MessageReceiptManagerTest { cid = deliveredMessage.cid, ), ) - fixture.verifyUpsertMessageReceiptsCalled(receipts) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) } @Test @@ -73,7 +75,7 @@ internal class MessageReceiptManagerTest { sut.markMessagesAsDelivered(messages) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -93,7 +95,7 @@ internal class MessageReceiptManagerTest { cid = message.cid, ) } - fixture.verifyUpsertMessageReceiptsCalled(receipts) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) } @Test @@ -109,7 +111,7 @@ internal class MessageReceiptManagerTest { sut.markMessagesAsDelivered(messages) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -120,7 +122,7 @@ internal class MessageReceiptManagerTest { sut.markMessagesAsDelivered(messages) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -131,7 +133,7 @@ internal class MessageReceiptManagerTest { sut.markMessagesAsDelivered(messages) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -142,7 +144,7 @@ internal class MessageReceiptManagerTest { sut.markMessagesAsDelivered(messages) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -153,7 +155,7 @@ internal class MessageReceiptManagerTest { sut.markMessagesAsDelivered(messages) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -192,7 +194,7 @@ internal class MessageReceiptManagerTest { cid = deliveredMessage.cid, ), ) - fixture.verifyUpsertMessageReceiptsCalled(receipts) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) } @Test @@ -203,7 +205,7 @@ internal class MessageReceiptManagerTest { sut.markChannelsAsDelivered(channels = listOf(channel)) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -218,7 +220,7 @@ internal class MessageReceiptManagerTest { sut.markChannelsAsDelivered(channels) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -233,7 +235,7 @@ internal class MessageReceiptManagerTest { sut.markChannelsAsDelivered(channels) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -248,7 +250,7 @@ internal class MessageReceiptManagerTest { sut.markChannelsAsDelivered(channels) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -275,7 +277,7 @@ internal class MessageReceiptManagerTest { sut.markChannelsAsDelivered(channels) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test @@ -302,7 +304,7 @@ internal class MessageReceiptManagerTest { sut.markChannelsAsDelivered(channels) - fixture.verifyUpsertNotCalled() + fixture.verifyUpsertMessageReceiptsCalled(never()) } private class Fixture { @@ -313,12 +315,13 @@ internal class MessageReceiptManagerTest { getCurrentUser = { user } } - fun verifyUpsertMessageReceiptsCalled(receipts: List) { - verifyBlocking(mockMessageReceiptRepository) { upsertMessageReceipts(receipts) } - } - - fun verifyUpsertNotCalled() { - verifyBlocking(mockMessageReceiptRepository, never()) { upsertMessageReceipts(any()) } + fun verifyUpsertMessageReceiptsCalled( + mode: VerificationMode = times(1), + receipts: List? = null, + ) { + verifyBlocking(mockMessageReceiptRepository, mode) { + upsertMessageReceipts(receipts ?: any()) + } } fun get() = MessageReceiptManager( From 9b737d8d3b454d16803815ece64459aa39e5cd04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 10:26:14 +0000 Subject: [PATCH 22/58] Automatically mark messages as delivered when querying channels Introduced a `MessageDeliveredPlugin` that automatically marks messages as delivered upon a successful `queryChannels` call. This plugin is enabled by default. --- .../chat/android/client/ChatClient.kt | 6 +- .../client/plugin/MessageDeliveredPlugin.kt | 43 ++++++++++++ .../MessageDeliveredPluginFactoryTest.kt | 38 ++++++++++ .../plugin/MessageDeliveredPluginTest.kt | 70 +++++++++++++++++++ 4 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginFactoryTest.kt create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt 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 b7ae74ea1f9..b4a4c9d1039 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 @@ -123,6 +123,7 @@ import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.persistance.repository.factory.RepositoryFactory import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory import io.getstream.chat.android.client.plugin.DependencyResolver +import io.getstream.chat.android.client.plugin.MessageDeliveredPluginFactory import io.getstream.chat.android.client.plugin.Plugin import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.plugin.factory.ThrottlingPluginFactory @@ -4817,7 +4818,10 @@ internal constructor( * @see [Plugin] * @see [PluginFactory] */ - protected val pluginFactories: MutableList = mutableListOf(ThrottlingPluginFactory) + protected val pluginFactories: MutableList = mutableListOf( + ThrottlingPluginFactory, + MessageDeliveredPluginFactory, + ) /** * Create a [ChatClient] instance based on the current configuration diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt new file mode 100644 index 00000000000..3e1f3e37f97 --- /dev/null +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2014-2025 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.plugin + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryChannelsRequest +import io.getstream.chat.android.client.plugin.factory.PluginFactory +import io.getstream.chat.android.client.receipts.MessageReceiptManager +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.User +import io.getstream.result.Result + +/** + * A plugin that marks messages as delivered when channels are queried. + */ +internal class MessageDeliveredPlugin( + chatClient: ChatClient = ChatClient.instance(), + private val messageReceiptManager: MessageReceiptManager = chatClient.messageReceiptManager, +) : Plugin { + override suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { + result.onSuccess { channels -> + messageReceiptManager.markChannelsAsDelivered(channels) + } + } +} + +internal object MessageDeliveredPluginFactory : PluginFactory { + override fun get(user: User): Plugin = MessageDeliveredPlugin() +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginFactoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginFactoryTest.kt new file mode 100644 index 00000000000..14805bd7992 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginFactoryTest.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2014-2025 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.plugin + +import io.getstream.chat.android.client.ChatClient +import org.junit.Test +import org.junit.jupiter.api.assertInstanceOf +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock + +internal class MessageDeliveredPluginFactoryTest { + + @Test + fun `factory should create MessageDeliveredPlugin`() { + val mockChatClient = mock { on { messageReceiptManager } doReturn mock() } + object : ChatClient.ChatClientBuilder() { + override fun internalBuild(): ChatClient = mockChatClient + }.build() + + val actual = MessageDeliveredPluginFactory.get(mock()) + + assertInstanceOf(actual) + } +} diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt new file mode 100644 index 00000000000..5e099e0c048 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2014-2025 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.plugin + +import io.getstream.chat.android.client.receipts.MessageReceiptManager +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.randomChannel +import io.getstream.result.Result +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.verification.VerificationMode + +internal class MessageDeliveredPluginTest { + + @Test + fun `on query channels with successful result, should mark channels as delivered`() = runTest { + val channels = listOf(randomChannel()) + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelsResult(result = Result.Success(channels), request = mock()) + + fixture.verifyMarkChannelsAsDeliveredCalled(channels = channels) + } + + @Test + fun `on query channels with failure result, should not mark channels as delivered`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelsResult(result = Result.Failure(mock()), request = mock()) + + fixture.verifyMarkChannelsAsDeliveredCalled(never()) + } + + private class Fixture { + private val mockMessageReceiptManager = mock() + + fun verifyMarkChannelsAsDeliveredCalled( + mode: VerificationMode = times(1), + channels: List? = null, + ) { + verify(mockMessageReceiptManager, mode).markChannelsAsDelivered(channels ?: any()) + } + + fun get() = MessageDeliveredPlugin( + chatClient = mock(), + messageReceiptManager = mockMessageReceiptManager, + ) + } +} From 86aae64c475554296e0ae06d7f07c7c86000156a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 10:31:02 +0000 Subject: [PATCH 23/58] Introduce `ChatClientRepository` to encapsulate internal repositories used by `ChatClient`. This new repository is now a required dependency for `ChatClient`. --- .../chat/android/client/ChatClient.kt | 18 +++++++++++++----- .../repository/ChatClientRepository.kt | 4 ++++ .../client/ChatClientConnectionTests.kt | 8 ++++++-- .../chat/android/client/ChatClientTest.kt | 7 ++++++- .../android/client/DependencyResolverTest.kt | 2 ++ .../chat/android/client/MockClientBuilder.kt | 1 + .../client/chatclient/BaseChatClientTest.kt | 1 + .../client/debugger/ChatClientDebuggerTest.kt | 5 +++++ 8 files changed, 38 insertions(+), 8 deletions(-) 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 b4a4c9d1039..b86e4f4939b 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 @@ -122,6 +122,8 @@ import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateForm import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.persistance.repository.factory.RepositoryFactory import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory +import io.getstream.chat.android.client.persistence.db.ChatClientDatabase +import io.getstream.chat.android.client.persistence.repository.ChatClientRepository import io.getstream.chat.android.client.plugin.DependencyResolver import io.getstream.chat.android.client.plugin.MessageDeliveredPluginFactory import io.getstream.chat.android.client.plugin.Plugin @@ -276,6 +278,7 @@ internal constructor( @InternalStreamChatApi public val audioPlayer: AudioPlayer, private val now: () -> Date = ::Date, + private val repository: ChatClientRepository, ) { private val logger by taggedLogger(TAG) private val waitConnection = MutableSharedFlow>() @@ -1509,6 +1512,8 @@ internal constructor( userCredentialStorage.clear() } + repository.clear() + _repositoryFacade = null attachmentsSender.cancelJobs() appSettingsManager.clear() @@ -4751,12 +4756,14 @@ internal constructor( isMarshmallowOrHigher = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M, ) + val database = ChatClientDatabase.build(appContext) + return ChatClient( - config, - module.api(), - module.dtoMapping, - module.notifications(), - tokenManager, + config = config, + api = module.api(), + dtoMapping = module.dtoMapping, + notifications = module.notifications(), + tokenManager = tokenManager, userCredentialStorage = userCredentialStorage ?: SharedPreferencesCredentialStorage(appContext), userStateService = module.userStateService, clientDebugger = clientDebugger ?: StubChatClientDebugger, @@ -4774,6 +4781,7 @@ internal constructor( mutableClientState = MutableClientState(module.networkStateProvider), currentUserFetcher = module.currentUserFetcher, audioPlayer = audioPlayer, + repository = ChatClientRepository.from(database), ).apply { attachmentsSender = AttachmentsSender( context = appContext, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt index 809d6383405..d5e37631be1 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepository.kt @@ -16,8 +16,12 @@ package io.getstream.chat.android.client.persistence.repository +import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.persistence.db.ChatClientDatabase +/** + * Repository that aggregates all internal repositories used by [ChatClient]. + */ internal class ChatClientRepository( private val messageReceiptRepository: MessageReceiptRepository, ) : MessageReceiptRepository by messageReceiptRepository { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt index 9476f6c1f87..24d0b76cbcf 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt @@ -26,12 +26,12 @@ import io.getstream.chat.android.client.events.ErrorEvent import io.getstream.chat.android.client.network.NetworkStateProvider import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateFormatter import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory +import io.getstream.chat.android.client.persistence.repository.ChatClientRepository import io.getstream.chat.android.client.scope.ClientTestScope import io.getstream.chat.android.client.scope.UserTestScope import io.getstream.chat.android.client.setup.state.internal.MutableClientState import io.getstream.chat.android.client.socket.FakeChatSocket import io.getstream.chat.android.client.token.FakeTokenManager -import io.getstream.chat.android.client.token.TokenManager import io.getstream.chat.android.client.user.CredentialConfig import io.getstream.chat.android.client.user.storage.UserCredentialStorage import io.getstream.chat.android.client.utils.TokenUtils @@ -60,6 +60,7 @@ import org.amshove.kluent.shouldBeInstanceOf import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -91,7 +92,6 @@ internal class ChatClientConnectionTests { private val mutableClientState: MutableClientState = MutableClientState(mock()) private val streamDateFormatter = StreamDateFormatter() private val config = mock() - private val tokenManager = mock() private val userCredentialStorage = mock() @BeforeEach @@ -114,6 +114,9 @@ internal class ChatClientConnectionTests { tokenManager = tokenManager, networkStateProvider = networkStateProvider, ) + val mockRepository = mock { + onBlocking { getAllMessageReceiptsByType(type = any(), limit = any()) } doReturn emptyList() + } client = ChatClient( config = config, api = chatApi, @@ -133,6 +136,7 @@ internal class ChatClientConnectionTests { mutableClientState = mutableClientState, currentUserFetcher = mock(), audioPlayer = mock(), + repository = mockRepository, ).apply { attachmentsSender = mock() } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt index c9e13ddc11a..fb1187c6f29 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt @@ -35,6 +35,7 @@ import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.parser.EventArguments import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateFormatter import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory +import io.getstream.chat.android.client.persistence.repository.ChatClientRepository import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.scope.ClientTestScope import io.getstream.chat.android.client.scope.UserTestScope @@ -97,8 +98,8 @@ internal class ChatClientTest { val tokenUtils: TokenUtils = mock() var pluginFactories: List = emptyList() var errorHandlerFactories: List = emptyList() - private val streamDateFormatter = StreamDateFormatter() + @Suppress("LongMethod") @BeforeEach fun setUp() { val apiKey = "api-key" @@ -133,6 +134,9 @@ internal class ChatClientTest { wssUrl = wssUrl, networkStateProvider = networkStateProvider, ) + val mockRepository = mock { + onBlocking { getAllMessageReceiptsByType(type = any(), limit = any()) } doReturn emptyList() + } client = ChatClient( config = config, api = api, @@ -152,6 +156,7 @@ internal class ChatClientTest { repositoryFactoryProvider = NoOpRepositoryFactory.Provider, currentUserFetcher = mock(), audioPlayer = mock(), + repository = mockRepository, ).apply { attachmentsSender = mock() connectUser(user, token).enqueue() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt index 7478655da81..0506d2f44e1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client +import io.getstream.chat.android.client.DependencyResolverTest.Companion.initializationStatesArguments import io.getstream.chat.android.client.api2.mapping.DtoMapping import io.getstream.chat.android.client.plugin.Plugin import io.getstream.chat.android.client.plugin.factory.PluginFactory @@ -178,6 +179,7 @@ public class DependencyResolverTest { mutableClientState = mutableClientState, currentUserFetcher = mock(), audioPlayer = mock(), + repository = mock(), ).apply { this.plugins = this@Fixture.plugins } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt index e776b73b57f..3e0741330cd 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt @@ -126,6 +126,7 @@ internal class MockClientBuilder( mutableClientState = mutableClientState, currentUserFetcher = mock(), audioPlayer = streamPlayer, + repository = mock(), ) client.attachmentsSender = attachmentSender diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt index 5351d0f6c73..d9d13467da2 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt @@ -128,6 +128,7 @@ internal open class BaseChatClientTest { currentUserFetcher = currentUserFetcher, audioPlayer = mock(), now = { now }, + repository = mock(), ) chatClient.attachmentsSender = attachmentsSender chatClient.plugins = plugins diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt index a8fa1e72336..7a5feb8d5f9 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt @@ -29,6 +29,7 @@ import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.network.NetworkStateProvider import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory +import io.getstream.chat.android.client.persistence.repository.ChatClientRepository import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.scope.ClientTestScope import io.getstream.chat.android.client.scope.UserTestScope @@ -123,6 +124,9 @@ internal class ChatClientDebuggerTest { isRetrying: Boolean, ): SendMessageDebugger = sendMessageDebugger } + val mockRepository = mock { + onBlocking { getAllMessageReceiptsByType(type = any(), limit = any()) } doReturn emptyList() + } client = ChatClient( config = config, api = api, @@ -143,6 +147,7 @@ internal class ChatClientDebuggerTest { repositoryFactoryProvider = NoOpRepositoryFactory.Provider, currentUserFetcher = mock(), audioPlayer = mock(), + repository = mockRepository, ).apply { attachmentsSender = this@ChatClientDebuggerTest.attachmentsSender connectUser(user, token).enqueue() From aa09f5031552b79294a0c4e31cfdd0b4b1ab0e6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 11:05:44 +0000 Subject: [PATCH 24/58] Add MessageReceiptManager and MessageReceiptReporter to ChatClient --- .../chat/android/client/ChatClient.kt | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) 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 b86e4f4939b..e3654d85181 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 @@ -131,6 +131,8 @@ import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.plugin.factory.ThrottlingPluginFactory import io.getstream.chat.android.client.query.AddMembersParams import io.getstream.chat.android.client.query.CreateChannelParams +import io.getstream.chat.android.client.receipts.MessageReceiptManager +import io.getstream.chat.android.client.receipts.MessageReceiptReporter import io.getstream.chat.android.client.scope.ClientScope import io.getstream.chat.android.client.scope.UserScope import io.getstream.chat.android.client.setup.state.ClientState @@ -331,6 +333,19 @@ internal constructor( private var _repositoryFacade: RepositoryFacade? = null + internal val messageReceiptManager = MessageReceiptManager( + scope = userScope, + now = now, + getCurrentUser = ::getCurrentUser, + messageReceiptRepository = repository, + ) + + private val messageReceiptReporter = MessageReceiptReporter( + scope = userScope, + chatClient = this, + messageReceiptRepository = repository, + ) + private var pushNotificationReceivedListener: PushNotificationReceivedListener = PushNotificationReceivedListener { _, _ -> } @@ -451,13 +466,13 @@ internal constructor( mutableClientState.setUser(user) } - is NewMessageEvent, - is NotificationReminderDueEvent, - -> { - // No other events should potentially show notifications + is NewMessageEvent -> { notifications.onChatEvent(event) + messageReceiptManager.markMessagesAsDelivered(messages = listOf(event.message)) } + is NotificationReminderDueEvent -> notifications.onChatEvent(event) + is ConnectingEvent -> { logger.i { "[handleEvent] event: ConnectingEvent" } mutableClientState.setConnectionState(ConnectionState.Connecting) @@ -646,6 +661,7 @@ internal constructor( tokenManager.setTokenProvider(tokenProvider) appSettingsManager.loadAppSettings() warmUp() + messageReceiptReporter.start() logger.i { "[initializeClientWithUser] user.id: '${user.id}'completed" } } @@ -2906,15 +2922,14 @@ internal constructor( * @param messages The list of messages to mark as delivered. */ @CheckResult - public fun markMessagesAsDelivered(messages: List): Call { - return api.markDelivered(messages) + public fun markMessagesAsDelivered(messages: List): Call = + api.markDelivered(messages) .doOnStart(userScope) { logger.d { "[markMessagesAsDelivered] #doOnStart; messages: ${messages.size}" } } .doOnResult(userScope) { result -> logger.v { "[markMessagesAsDelivered] #doOnResult; completed: $result" } } - } /** * Marks a given thread as read. From aca01376f4da7c043b68557079871e8798ca5e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 14:48:10 +0000 Subject: [PATCH 25/58] Decoupled `MessageReceiptReporter` from `ChatClient` by passing `ChatApi` directly to its constructor. This simplifies dependencies and removes the need for `ChatClient` as an intermediary for API calls. Updated tests to reflect this change, mocking `ChatApi` instead of `ChatClient`. --- .../chat/android/client/ChatClient.kt | 20 +++++---- .../client/receipts/MessageReceiptReporter.kt | 6 +-- .../client/ChatClientConnectionTests.kt | 8 +--- .../chat/android/client/ChatClientTest.kt | 6 +-- .../android/client/DependencyResolverTest.kt | 1 + .../chat/android/client/MockClientBuilder.kt | 1 + .../client/chatclient/BaseChatClientTest.kt | 1 + .../client/debugger/ChatClientDebuggerTest.kt | 1 + .../receipts/MessageReceiptReporterTest.kt | 43 +++++++++++-------- 9 files changed, 46 insertions(+), 41 deletions(-) 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 e3654d85181..c376c2a8d90 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 @@ -281,6 +281,7 @@ internal constructor( public val audioPlayer: AudioPlayer, private val now: () -> Date = ::Date, private val repository: ChatClientRepository, + private val messageReceiptReporter: MessageReceiptReporter, ) { private val logger by taggedLogger(TAG) private val waitConnection = MutableSharedFlow>() @@ -340,12 +341,6 @@ internal constructor( messageReceiptRepository = repository, ) - private val messageReceiptReporter = MessageReceiptReporter( - scope = userScope, - chatClient = this, - messageReceiptRepository = repository, - ) - private var pushNotificationReceivedListener: PushNotificationReceivedListener = PushNotificationReceivedListener { _, _ -> } @@ -4756,7 +4751,8 @@ internal constructor( appVersion = this.appVersion, ) - val appSettingsManager = AppSettingManager(module.api()) + val api = module.api() + val appSettingsManager = AppSettingManager(api) val audioPlayer: AudioPlayer = StreamMediaPlayer( mediaPlayer = NativeMediaPlayerImpl { @@ -4772,10 +4768,11 @@ internal constructor( ) val database = ChatClientDatabase.build(appContext) + val repository = ChatClientRepository.from(database) return ChatClient( config = config, - api = module.api(), + api = api, dtoMapping = module.dtoMapping, notifications = module.notifications(), tokenManager = tokenManager, @@ -4796,7 +4793,12 @@ internal constructor( mutableClientState = MutableClientState(module.networkStateProvider), currentUserFetcher = module.currentUserFetcher, audioPlayer = audioPlayer, - repository = ChatClientRepository.from(database), + repository = repository, + messageReceiptReporter = MessageReceiptReporter( + scope = userScope, + messageReceiptRepository = repository, + api = api, + ), ).apply { attachmentsSender = AttachmentsSender( context = appContext, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt index bf43a83f9c6..34904d779c7 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptReporter.kt @@ -16,7 +16,7 @@ package io.getstream.chat.android.client.receipts -import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.models.Message import io.getstream.log.taggedLogger @@ -32,8 +32,8 @@ import kotlinx.coroutines.launch */ internal class MessageReceiptReporter( private val scope: CoroutineScope, - private val chatClient: ChatClient, private val messageReceiptRepository: MessageReceiptRepository, + private val api: ChatApi, ) { private val logger by taggedLogger("Chat:MessageReceiptReporter") @@ -55,7 +55,7 @@ internal class MessageReceiptReporter( if (messages.isNotEmpty()) { logger.d { "Reporting delivery receipts for ${messages.size} messages…" } - chatClient.markMessagesAsDelivered(messages) + api.markDelivered(messages) .execute() .onSuccessSuspend { logger.d { "Successfully reported delivery receipts for ${messages.size} messages" } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt index 24d0b76cbcf..24a7103140f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientConnectionTests.kt @@ -26,7 +26,6 @@ import io.getstream.chat.android.client.events.ErrorEvent import io.getstream.chat.android.client.network.NetworkStateProvider import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateFormatter import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory -import io.getstream.chat.android.client.persistence.repository.ChatClientRepository import io.getstream.chat.android.client.scope.ClientTestScope import io.getstream.chat.android.client.scope.UserTestScope import io.getstream.chat.android.client.setup.state.internal.MutableClientState @@ -60,7 +59,6 @@ import org.amshove.kluent.shouldBeInstanceOf import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension -import org.mockito.kotlin.any import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock @@ -114,9 +112,6 @@ internal class ChatClientConnectionTests { tokenManager = tokenManager, networkStateProvider = networkStateProvider, ) - val mockRepository = mock { - onBlocking { getAllMessageReceiptsByType(type = any(), limit = any()) } doReturn emptyList() - } client = ChatClient( config = config, api = chatApi, @@ -136,7 +131,8 @@ internal class ChatClientConnectionTests { mutableClientState = mutableClientState, currentUserFetcher = mock(), audioPlayer = mock(), - repository = mockRepository, + repository = mock(), + messageReceiptReporter = mock(), ).apply { attachmentsSender = mock() } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt index fb1187c6f29..9ff33d4d721 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt @@ -134,9 +134,6 @@ internal class ChatClientTest { wssUrl = wssUrl, networkStateProvider = networkStateProvider, ) - val mockRepository = mock { - onBlocking { getAllMessageReceiptsByType(type = any(), limit = any()) } doReturn emptyList() - } client = ChatClient( config = config, api = api, @@ -156,7 +153,8 @@ internal class ChatClientTest { repositoryFactoryProvider = NoOpRepositoryFactory.Provider, currentUserFetcher = mock(), audioPlayer = mock(), - repository = mockRepository, + repository = mock(), + messageReceiptReporter = mock(), ).apply { attachmentsSender = mock() connectUser(user, token).enqueue() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt index 0506d2f44e1..537831f06c8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/DependencyResolverTest.kt @@ -180,6 +180,7 @@ public class DependencyResolverTest { currentUserFetcher = mock(), audioPlayer = mock(), repository = mock(), + messageReceiptReporter = mock(), ).apply { this.plugins = this@Fixture.plugins } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt index 3e0741330cd..43a474b76a1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/MockClientBuilder.kt @@ -127,6 +127,7 @@ internal class MockClientBuilder( currentUserFetcher = mock(), audioPlayer = streamPlayer, repository = mock(), + messageReceiptReporter = mock(), ) client.attachmentsSender = attachmentSender diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt index d9d13467da2..7ad1913d491 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/chatclient/BaseChatClientTest.kt @@ -129,6 +129,7 @@ internal open class BaseChatClientTest { audioPlayer = mock(), now = { now }, repository = mock(), + messageReceiptReporter = mock(), ) chatClient.attachmentsSender = attachmentsSender chatClient.plugins = plugins diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt index 7a5feb8d5f9..bbd98146196 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/debugger/ChatClientDebuggerTest.kt @@ -148,6 +148,7 @@ internal class ChatClientDebuggerTest { currentUserFetcher = mock(), audioPlayer = mock(), repository = mockRepository, + messageReceiptReporter = mock(), ).apply { attachmentsSender = this@ChatClientDebuggerTest.attachmentsSender connectUser(user, token).enqueue() diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt index bb07ae4238b..bcd128af276 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptReporterTest.kt @@ -16,7 +16,7 @@ package io.getstream.chat.android.client.receipts -import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.client.randomMessageReceipt import io.getstream.chat.android.models.Message @@ -56,13 +56,13 @@ internal class MessageReceiptReporterTest { } val fixture = Fixture() .givenMessageReceipts(receipts) - .givenMarkMessagesAsDelivered(messages) + .givenMarkDelivered(messages) val sut = fixture.get(backgroundScope) sut.start() advanceTimeBy(100) // Allow initial execution - fixture.verifyMarkMessagesAsDeliveredCalled(messages = messages) + fixture.verifyMarkDeliveredCalled(messages = messages) val messageIds = messages.map(Message::id) fixture.verifyDeleteByMessageIdsCalled(messageIds = messageIds) } @@ -72,7 +72,7 @@ internal class MessageReceiptReporterTest { val fixture = Fixture() .givenMessageReceipts(listOf(randomMessageReceipt())) // Simulate an error when marking messages as delivered - .givenMarkMessagesAsDelivered(error = mock()) + .givenMarkDelivered(error = mock()) val sut = fixture.get(backgroundScope) sut.start() @@ -81,10 +81,10 @@ internal class MessageReceiptReporterTest { fixture.verifyDeleteByMessageIdsCalled(never()) // Keep processing in the next time window - fixture.givenMarkMessagesAsDelivered() + fixture.givenMarkDelivered() advanceTimeBy(1000) - fixture.verifyMarkMessagesAsDeliveredCalled(times(2)) + fixture.verifyMarkDeliveredCalled(times(2)) fixture.verifyDeleteByMessageIdsCalled() } @@ -97,7 +97,7 @@ internal class MessageReceiptReporterTest { sut.start() advanceTimeBy(100) // Allow initial execution - fixture.verifyMarkMessagesAsDeliveredCalled(never()) + fixture.verifyMarkDeliveredCalled(never()) fixture.verifyDeleteByMessageIdsCalled(never()) } @@ -105,7 +105,7 @@ internal class MessageReceiptReporterTest { fun `should execute periodically with correct delay`() = runTest { val fixture = Fixture() .givenMessageReceipts(listOf(randomMessageReceipt())) - .givenMarkMessagesAsDelivered() + .givenMarkDelivered() val sut = fixture.get(backgroundScope) sut.start() @@ -117,14 +117,14 @@ internal class MessageReceiptReporterTest { advanceTimeBy(1000) // Advance to the fourth interval - fixture.verifyMarkMessagesAsDeliveredCalled(times(4)) + fixture.verifyMarkDeliveredCalled(times(4)) } @Test fun `should stop execution when coroutine scope is cancelled`() = runTest { val fixture = Fixture() .givenMessageReceipts(listOf(randomMessageReceipt())) - .givenMarkMessagesAsDelivered() + .givenMarkDelivered() val sut = fixture.get(backgroundScope) sut.start() @@ -134,13 +134,12 @@ internal class MessageReceiptReporterTest { advanceTimeBy(1000) // Try to advance time after cancellation - fixture.verifyMarkMessagesAsDeliveredCalled(times(1)) + fixture.verifyMarkDeliveredCalled(times(1)) } private class Fixture { - private val mockChatClient = mock() - private val mockMessageReceiptRepository = mock() + private val mockApi = mock() fun givenMessageReceipts(receipts: List) = apply { wheneverBlocking { @@ -151,16 +150,22 @@ internal class MessageReceiptReporterTest { } doReturn receipts } - fun givenMarkMessagesAsDelivered(messages: List? = null, error: Error? = null) = apply { - whenever(mockChatClient.markMessagesAsDelivered(messages ?: any())) doReturn + fun givenMarkDelivered(messages: List? = null, error: Error? = null) = apply { + whenever(mockApi.markDelivered(messages ?: any())) doReturn (error?.asCall() ?: Unit.asCall()) } - fun verifyMarkMessagesAsDeliveredCalled(mode: VerificationMode = times(1), messages: List? = null) { - verify(mockChatClient, mode).markMessagesAsDelivered(messages ?: any()) + fun verifyMarkDeliveredCalled( + mode: VerificationMode = times(1), + messages: List? = null, + ) { + verify(mockApi, mode).markDelivered(messages ?: any()) } - fun verifyDeleteByMessageIdsCalled(mode: VerificationMode = times(1), messageIds: List? = null) { + fun verifyDeleteByMessageIdsCalled( + mode: VerificationMode = times(1), + messageIds: List? = null, + ) { verifyBlocking(mockMessageReceiptRepository, mode) { deleteMessageReceiptsByMessageIds(messageIds ?: any()) } @@ -168,8 +173,8 @@ internal class MessageReceiptReporterTest { fun get(scope: CoroutineScope) = MessageReceiptReporter( scope = scope, - chatClient = mockChatClient, messageReceiptRepository = mockMessageReceiptRepository, + api = mockApi, ) } } From 22c462283d18d22a9fb527e893057b6a53400963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 14:48:34 +0000 Subject: [PATCH 26/58] Refactor ChatClientTest to simplify test setup - Remove unused properties and simplify the `setUp` method. - Make test properties `private`. - Remove `runTest` from test function signatures as it's already handled by the `TestCoroutineExtension`. --- .../chat/android/client/ChatClientTest.kt | 76 ++++++++----------- 1 file changed, 32 insertions(+), 44 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt index 9ff33d4d721..d2e01453e0c 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt @@ -18,25 +18,20 @@ package io.getstream.chat.android.client import androidx.lifecycle.Lifecycle import androidx.lifecycle.testing.TestLifecycleOwner -import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.api.ChatClientConfig import io.getstream.chat.android.client.api2.mapping.DtoMapping import io.getstream.chat.android.client.clientstate.DisconnectCause import io.getstream.chat.android.client.clientstate.UserStateService -import io.getstream.chat.android.client.errorhandler.factory.ErrorHandlerFactory import io.getstream.chat.android.client.errors.ChatErrorCode import io.getstream.chat.android.client.events.ChatEvent import io.getstream.chat.android.client.events.DisconnectedEvent import io.getstream.chat.android.client.events.UnknownEvent import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.client.network.NetworkStateProvider -import io.getstream.chat.android.client.notifications.ChatNotifications import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.parser.EventArguments import io.getstream.chat.android.client.parser2.adapters.internal.StreamDateFormatter import io.getstream.chat.android.client.persistance.repository.noop.NoOpRepositoryFactory -import io.getstream.chat.android.client.persistence.repository.ChatClientRepository -import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.scope.ClientTestScope import io.getstream.chat.android.client.scope.UserTestScope import io.getstream.chat.android.client.socket.FakeChatSocket @@ -70,7 +65,7 @@ import java.util.Date internal class ChatClientTest { - companion object { + private companion object { @JvmField @RegisterExtension val testCoroutines = TestCoroutineExtension() @@ -87,43 +82,36 @@ internal class ChatClientTest { val eventF = UnknownEvent("f", createdAt, rawCreatedAt, null, emptyMap()) } - lateinit var lifecycleOwner: TestLifecycleOwner - lateinit var api: ChatApi - lateinit var client: ChatClient - lateinit var fakeChatSocket: FakeChatSocket - lateinit var result: MutableList - val token = randomString() - val userId = randomString() - val user = randomUser(id = userId) - val tokenUtils: TokenUtils = mock() - var pluginFactories: List = emptyList() - var errorHandlerFactories: List = emptyList() - - @Suppress("LongMethod") + private lateinit var lifecycleOwner: TestLifecycleOwner + private lateinit var client: ChatClient + private lateinit var fakeChatSocket: FakeChatSocket + private lateinit var result: MutableList + private val token = randomString() + private val userId = randomString() + private val user = randomUser(id = userId) + private val tokenUtils: TokenUtils = mock() + @BeforeEach fun setUp() { val apiKey = "api-key" val wssUrl = "socket.url" val config = ChatClientConfig( - apiKey, - "hello.http", - "cdn.http", - wssUrl, - false, - Mother.chatLoggerConfig(), - false, - false, - NotificationConfig(), + apiKey = apiKey, + httpUrl = "hello.http", + cdnHttpUrl = "cdn.http", + wssUrl = wssUrl, + warmUp = false, + loggerConfig = Mother.chatLoggerConfig(), + distinctApiCalls = false, + debugRequests = false, + notificationConfig = NotificationConfig(), ) whenever(tokenUtils.getUserId(token)) doReturn userId lifecycleOwner = TestLifecycleOwner(coroutineDispatcher = testCoroutines.dispatcher) - api = mock() - val userStateService = UserStateService() val clientScope = ClientTestScope(testCoroutines.scope) val userScope = UserTestScope(clientScope) val lifecycleObserver = StreamLifecycleObserver(userScope, lifecycleOwner.lifecycle) val tokenManager = FakeTokenManager("") - val notifications = mock() val networkStateProvider: NetworkStateProvider = mock() whenever(networkStateProvider.isConnected()) doReturn true fakeChatSocket = FakeChatSocket( @@ -136,19 +124,19 @@ internal class ChatClientTest { ) client = ChatClient( config = config, - api = api, + api = mock(), dtoMapping = DtoMapping(NoOpMessageTransformer, NoOpUserTransformer), - notifications = notifications, + notifications = mock(), tokenManager = tokenManager, userCredentialStorage = mock(), - userStateService = userStateService, + userStateService = UserStateService(), tokenUtils = tokenUtils, clientScope = clientScope, userScope = userScope, retryPolicy = NoRetryPolicy(), appSettingsManager = mock(), chatSocket = fakeChatSocket, - pluginFactories = pluginFactories, + pluginFactories = emptyList(), mutableClientState = Mother.mockedClientState(), repositoryFactoryProvider = NoOpRepositoryFactory.Provider, currentUserFetcher = mock(), @@ -184,7 +172,7 @@ internal class ChatClientTest { } @Test - fun `Simple subscribe for one event`() = runTest { + fun `Simple subscribe for one event`() { client.subscribe { result.add(it) } @@ -195,7 +183,7 @@ internal class ChatClientTest { } @Test - fun `Simple subscribe for multiple events`() = runTest { + fun `Simple subscribe for multiple events`() { client.subscribe { result.add(it) } @@ -223,7 +211,7 @@ internal class ChatClientTest { } @Test - fun `Subscribe for Java Class event types`() = runTest { + fun `Subscribe for Java Class event types`() { client.subscribeFor(eventA::class.java, eventC::class.java) { result.add(it) } @@ -236,7 +224,7 @@ internal class ChatClientTest { } @Test - fun `Subscribe for KClass event types`() = runTest { + fun `Subscribe for KClass event types`() { client.subscribeFor(eventA::class, eventC::class) { result.add(it) } @@ -249,7 +237,7 @@ internal class ChatClientTest { } @Test - fun `Subscribe for event types with type parameter`() = runTest { + fun `Subscribe for event types with type parameter`() { client.subscribeFor { result.add(it) } @@ -262,7 +250,7 @@ internal class ChatClientTest { } @Test - fun `Subscribe for single string event type`() = runTest { + fun `Subscribe for single string event type`() { client.subscribeForSingle("d") { result.add(it) } @@ -276,7 +264,7 @@ internal class ChatClientTest { } @Test - fun `Subscribe for single event, with event type as type parameter`() = runTest { + fun `Subscribe for single event, with event type as type parameter`() { client.subscribeForSingle { result.add(it) } @@ -289,7 +277,7 @@ internal class ChatClientTest { } @Test - fun `Unsubscribe from events`() = runTest { + fun `Unsubscribe from events`() { val disposable = client.subscribe { result.add(it) } @@ -305,7 +293,7 @@ internal class ChatClientTest { } @Test - fun `Given connected user When handle event with updated user Should updated user value`() = runTest { + fun `Given connected user When handle event with updated user Should updated user value`() { val updateUser = user.copy( extraData = mutableMapOf(), name = "updateUserName", From 5764abe8383ba3fb8ad640da9cc7327deda278bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 15:19:33 +0000 Subject: [PATCH 27/58] Rename deliveredReads to deliveredReadsOf --- .../api/stream-chat-android-client.api | 2 +- .../android/client/extensions/ChannelExtension.kt | 15 ++++++++++----- .../client/receipts/MessageReceiptManager.kt | 1 + 3 files changed, 12 insertions(+), 6 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 f16f267192b..ea78c5dcb21 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -2659,7 +2659,7 @@ public final class io/getstream/chat/android/client/extensions/ChannelExtensionK public static final fun countUnreadMentionsForUser (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)I public static final fun currentUserUnreadCount (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)I public static synthetic fun currentUserUnreadCount$default (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;ILjava/lang/Object;)I - public static final fun deliveredReads (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Ljava/util/List; + public static final fun deliveredReadsOf (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Ljava/util/List; public static final fun isAnonymousChannel (Lio/getstream/chat/android/models/Channel;)Z public static final fun isArchive (Lio/getstream/chat/android/models/Channel;)Z public static final fun isMutedFor (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)Z diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt index 99089736982..08d435900ed 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt @@ -131,13 +131,18 @@ public fun Channel.userRead(userId: UserId): ChannelUserRead? = read.firstOrNull { read -> read.user.id == userId } /** - * Returns a list of [ChannelUserRead] objects representing users who have read the given [message]. + * Returns a list of [ChannelUserRead] objects representing which ones have + * delivered the given [message]. + * + * A message is considered delivered to a user if: + * - The user is not the sender of the message + * - The user has received (delivered) the message * * @param message The [Message] object for which to find delivered reads. - * @return A list of [ChannelUserRead] objects for users who have read the message + * @return A list of [ChannelUserRead] objects representing users who have delivered the message. */ -public fun Channel.deliveredReads(message: Message): List = +public fun Channel.deliveredReadsOf(message: Message): List = read.filter { read -> - (read.lastDeliveredAt ?: NEVER) > message.getCreatedAtOrThrow() && - read.user.id != message.user.id + read.user.id != message.user.id && + (read.lastDeliveredAt ?: NEVER) > message.getCreatedAtOrThrow() } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index 625ec09dac3..f02e8e3939c 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -124,6 +124,7 @@ internal class MessageReceiptManager( if (createdAt <= userRead.lastRead) return null // Check if the last message is already marked as delivered if (createdAt <= (userRead.lastDeliveredAt ?: NEVER)) return null + return lastMessage } From 0125106d8346fe2db1bf65f97bbc44310f2a1277 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 15:21:45 +0000 Subject: [PATCH 28/58] feat: Add delivered status indicator for messages This commit introduces a new "delivered" status for messages, which is displayed when a message has been delivered but not yet read. **Key changes:** - Added `isMessageDelivered` property to `MessageItemState` and `MessageListItem`. - Introduced a new grey double-check icon (`stream_ui_ic_check_double_grey`) for the delivered state. - Updated `ChannelListViewStyle` and `MessageListItemStyle` to include `indicatorDeliveredIcon`. - The message status indicator logic in both UI Components and Compose has been updated to show the delivered icon between the "sent" and "read" states. - Added a new `stream_ui_message_list_semantics_message_status_delivered` string for accessibility. - In Compose, `MessageFooterStatusIndicator` is updated to accept `MessageFooterStatusIndicatorParams` to handle the new delivered state, deprecating the old signature. --- .../api/stream-chat-android-compose.api | 16 +++- .../channels/MessageReadStatusIcon.kt | 37 +++++++-- .../ui/components/messages/MessageFooter.kt | 8 +- .../compose/ui/theme/ChatComponentFactory.kt | 34 ++++++++ .../ui/theme/ChatComponentFactoryParams.kt | 10 +++ .../api/stream-chat-android-ui-common.api | 20 ++--- .../messages/list/MessageListController.kt | 4 + .../messages/list/MessageListItemState.kt | 2 + .../src/main/res/values/strings.xml | 1 + .../api/stream-chat-android-ui-components.api | 80 ++++++++++--------- .../channels/list/ChannelListViewStyle.kt | 6 ++ .../viewholder/internal/ChannelViewHolder.kt | 10 ++- .../messages/list/MessageListItemStyle.kt | 6 ++ .../messages/list/adapter/MessageListItem.kt | 2 + .../decorator/internal/FootnoteDecorator.kt | 5 +- .../ui/utils/extensions/MessageListItem.kt | 1 + .../stream_ui_ic_check_double_grey.xml | 44 ++++++++++ .../res/values/attrs_channel_list_view.xml | 3 + .../res/values/attrs_message_list_view.xml | 1 + .../io/getstream/chat/android/ui/Mother.kt | 2 + 20 files changed, 232 insertions(+), 60 deletions(-) create mode 100644 stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_check_double_grey.xml diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 344e7798bfb..c4cd3dcc354 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1613,7 +1613,7 @@ public final class io/getstream/chat/android/compose/ui/components/channels/Comp public final class io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIconKt { public static final fun MessageReadStatusIcon (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V - public static final fun MessageReadStatusIcon (Lio/getstream/chat/android/models/Message;ZLandroidx/compose/ui/Modifier;ILkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun MessageReadStatusIcon (Lio/getstream/chat/android/models/Message;ZLandroidx/compose/ui/Modifier;ZILkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } public final class io/getstream/chat/android/compose/ui/components/channels/UnreadCountIndicatorKt { @@ -2908,6 +2908,7 @@ public abstract interface class io/getstream/chat/android/compose/ui/theme/ChatC public abstract fun MessageFooterContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageFooterOnlyVisibleToYouContent (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageFooterStatusIndicator (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Message;ZILandroidx/compose/runtime/Composer;I)V + public abstract fun MessageFooterStatusIndicator (Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageFooterUploadingContent (Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageGiphyContent (Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public abstract fun MessageItemCenterContent (Landroidx/compose/foundation/layout/ColumnScope;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V @@ -3089,6 +3090,7 @@ public final class io/getstream/chat/android/compose/ui/theme/ChatComponentFacto public static fun MessageFooterContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public static fun MessageFooterOnlyVisibleToYouContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public static fun MessageFooterStatusIndicator (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/models/Message;ZILandroidx/compose/runtime/Composer;I)V + public static fun MessageFooterStatusIndicator (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams;Landroidx/compose/runtime/Composer;I)V public static fun MessageFooterUploadingContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/ui/Modifier;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/runtime/Composer;I)V public static fun MessageGiphyContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Lio/getstream/chat/android/models/Message;Lio/getstream/chat/android/models/User;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;I)V public static fun MessageItemCenterContent (Lio/getstream/chat/android/compose/ui/theme/ChatComponentFactory;Landroidx/compose/foundation/layout/ColumnScope;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)V @@ -3522,6 +3524,18 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageDateSeparat public final fun defaultTheme (Lio/getstream/chat/android/compose/ui/theme/StreamTypography;Lio/getstream/chat/android/compose/ui/theme/StreamColors;Landroidx/compose/runtime/Composer;II)Lio/getstream/chat/android/compose/ui/theme/MessageDateSeparatorTheme; } +public final class io/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams { + public static final field $stable I + public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;)V + public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;)Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessageItem ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class io/getstream/chat/android/compose/ui/theme/MessageOptionsTheme { public static final field $stable I public static final field Companion Lio/getstream/chat/android/compose/ui/theme/MessageOptionsTheme$Companion; diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt index 0bcae9f7a04..f96952acbb2 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import io.getstream.chat.android.client.extensions.deliveredReadsOf import io.getstream.chat.android.client.extensions.getCreatedAtOrThrow import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.ui.theme.ChatTheme @@ -59,11 +60,13 @@ public fun MessageReadStatusIcon( val readStatuses = channel.getReadStatuses(userToIgnore = currentUser) val readCount = readStatuses.count { it.time >= message.getCreatedAtOrThrow().time } val isMessageRead = readCount != 0 + val isMessageDelivered = channel.deliveredReadsOf(message).isNotEmpty() MessageReadStatusIcon( modifier = modifier, message = message, isMessageRead = isMessageRead, + isMessageDelivered = isMessageDelivered, readCount = readCount, ) } @@ -73,6 +76,7 @@ public fun MessageReadStatusIcon( * * @param message The message with sync status to check. * @param isMessageRead If the message is read by any member. + * @param isMessageDelivered If the message is delivered to any member. * @param modifier Modifier for styling. */ @Composable @@ -80,19 +84,28 @@ public fun MessageReadStatusIcon( message: Message, isMessageRead: Boolean, modifier: Modifier = Modifier, + isMessageDelivered: Boolean = false, readCount: Int = 0, isReadIcon: @Composable () -> Unit = { IsReadCount(modifier = modifier, readCount = readCount) }, isPendingIcon: @Composable () -> Unit = { IsPendingIcon(modifier = modifier) }, isSentIcon: @Composable () -> Unit = { IsSentIcon(modifier = modifier) }, + isDeliveredIcon: @Composable () -> Unit = { IsDeliveredIcon(modifier = modifier) }, ) { val syncStatus = message.syncStatus - when { - isMessageRead -> isReadIcon() - syncStatus == SyncStatus.SYNC_NEEDED || - syncStatus == SyncStatus.AWAITING_ATTACHMENTS -> isPendingIcon() + when (syncStatus) { + SyncStatus.IN_PROGRESS, + SyncStatus.SYNC_NEEDED, + SyncStatus.AWAITING_ATTACHMENTS, + -> isPendingIcon() - syncStatus == SyncStatus.COMPLETED -> isSentIcon() + SyncStatus.COMPLETED -> when { + isMessageRead -> isReadIcon() + isMessageDelivered -> isDeliveredIcon() + else -> isSentIcon() + } + + SyncStatus.FAILED_PERMANENTLY -> Unit } } @@ -110,7 +123,7 @@ private fun IsReadCount( Row( modifier = modifier .semantics { contentDescription = description } - .padding(horizontal = 2.dp), + .padding(start = 2.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { @@ -151,6 +164,18 @@ private fun IsSentIcon(modifier: Modifier) { ) } +@Composable +private fun IsDeliveredIcon(modifier: Modifier) { + Icon( + modifier = Modifier.testTag("Stream_MessageReadStatus_isDelivered"), + painter = painterResource(id = R.drawable.stream_compose_message_seen), + contentDescription = stringResource( + R.string.stream_ui_message_list_semantics_message_status_delivered, + ), + tint = ChatTheme.colors.disabled, + ) +} + /** * Preview of [MessageReadStatusIcon] for a seen message. * diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt index b6a8f861d63..b15ec63f902 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt @@ -39,6 +39,7 @@ import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.DateFormatType import io.getstream.chat.android.compose.ui.components.Timestamp import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.compose.ui.theme.MessageFooterStatusIndicatorParams import io.getstream.chat.android.compose.ui.util.clickable import io.getstream.chat.android.core.utils.date.truncateFuture import io.getstream.chat.android.models.Message @@ -103,10 +104,9 @@ public fun MessageFooter( ) } else { ChatTheme.componentFactory.MessageFooterStatusIndicator( - modifier = Modifier.padding(end = 4.dp), - message = message, - isMessageRead = messageItem.isMessageRead, - readCount = messageItem.messageReadBy.size, + params = MessageFooterStatusIndicatorParams( + messageItem = messageItem, + ), ) } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index ab679a90218..5010e6397d6 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -1373,6 +1373,18 @@ public interface ChatComponentFactory { /** * The default read status indicator in the message footer, weather the message is sent, pending or read. */ + @Deprecated( + message = "Use the new version of MessageFooterStatusIndicator that takes MessageFooterStatusIndicatorParams.", + replaceWith = ReplaceWith( + "MessageFooterStatusIndicator(\n" + + " params = MessageFooterStatusIndicatorParams(\n" + + " modifier = modifier,\n" + + " messageItem = messageItem,\n" + + " ),\n" + + ")", + ), + level = DeprecationLevel.WARNING, + ) @Composable public fun MessageFooterStatusIndicator( modifier: Modifier, @@ -1388,6 +1400,28 @@ public interface ChatComponentFactory { ) } + @Composable + public fun MessageFooterStatusIndicator( + params: MessageFooterStatusIndicatorParams, + ) { + if (params.messageItem.isMessageDelivered) { + MessageReadStatusIcon( + modifier = Modifier.padding(end = 4.dp), + message = params.messageItem.message, + isMessageRead = params.messageItem.isMessageRead, + isMessageDelivered = params.messageItem.isMessageDelivered, + readCount = params.messageItem.messageReadBy.size, + ) + } else { + MessageFooterStatusIndicator( + modifier = Modifier.padding(end = 4.dp), + message = params.messageItem.message, + isMessageRead = params.messageItem.isMessageRead, + readCount = params.messageItem.messageReadBy.size, + ) + } + } + /** * The default message composer that contains * the message input, attachments, commands, recording actions, integrations, and the send button. diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index c845dc3ce70..0fe26d38eff 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import io.getstream.chat.android.compose.state.reactionoptions.ReactionOptionItemState import io.getstream.chat.android.models.Message +import io.getstream.chat.android.ui.common.state.messages.list.MessageItemState /** * Parameters for the [ChatComponentFactory.MessageReactionList] component. @@ -59,3 +60,12 @@ public data class ChannelMediaAttachmentsPreviewBottomBarParams( val leadingContent: @Composable () -> Unit = {}, val trailingContent: @Composable () -> Unit = {}, ) + +/** + * Parameters for the [ChatComponentFactory.MessageFooterStatusIndicator] component. + * + * @param messageItem The message item state. + */ +public data class MessageFooterStatusIndicatorParams( + val messageItem: MessageItemState, +) diff --git a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api index f5db60e8287..3b643fb4f7c 100644 --- a/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api +++ b/stream-chat-android-ui-common/api/stream-chat-android-ui-common.api @@ -2433,13 +2433,14 @@ public final class io/getstream/chat/android/ui/common/state/messages/list/Messa public final class io/getstream/chat/android/ui/common/state/messages/list/MessageItemState : io/getstream/chat/android/ui/common/state/messages/list/HasMessageListItemState { public static final field $stable I - public fun (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;)V - public synthetic fun (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;)V + public synthetic fun (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; - public final fun component10 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState; - public final fun component11 ()Ljava/util/List; - public final fun component12 ()Z - public final fun component13 ()Ljava/util/Set; + public final fun component10 ()Lio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility; + public final fun component11 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState; + public final fun component12 ()Ljava/util/List; + public final fun component13 ()Z + public final fun component14 ()Ljava/util/Set; public final fun component2 ()Ljava/lang/String; public final fun component3 ()Z public final fun component4 ()Z @@ -2447,9 +2448,9 @@ public final class io/getstream/chat/android/ui/common/state/messages/list/Messa public final fun component6 ()Lio/getstream/chat/android/models/User; public final fun component7 ()Ljava/util/List; public final fun component8 ()Z - public final fun component9 ()Lio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility; - public final fun copy (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public final fun component9 ()Z + public final fun copy (Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Lio/getstream/chat/android/models/Message;Ljava/lang/String;ZZZLio/getstream/chat/android/models/User;Ljava/util/List;ZZLio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility;Lio/getstream/chat/android/ui/common/state/messages/list/MessageFocusState;Ljava/util/List;ZLjava/util/Set;ILjava/lang/Object;)Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; public fun equals (Ljava/lang/Object;)Z public final fun getCurrentUser ()Lio/getstream/chat/android/models/User; public final fun getDeletedMessageVisibility ()Lio/getstream/chat/android/ui/common/state/messages/list/DeletedMessageVisibility; @@ -2463,6 +2464,7 @@ public final class io/getstream/chat/android/ui/common/state/messages/list/Messa public final fun getShowOriginalText ()Z public fun hashCode ()I public final fun isInThread ()Z + public final fun isMessageDelivered ()Z public final fun isMessageRead ()Z public final fun isMine ()Z public fun toString ()Ljava/lang/String; diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt index 2aa6f08e79f..2f9001df1d5 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/list/MessageListController.kt @@ -23,6 +23,7 @@ import io.getstream.chat.android.client.audio.audioHash import io.getstream.chat.android.client.channel.state.ChannelState import io.getstream.chat.android.client.errors.extractCause import io.getstream.chat.android.client.extensions.cidToTypeAndId +import io.getstream.chat.android.client.extensions.deliveredReadsOf import io.getstream.chat.android.client.extensions.getCreatedAtOrDefault import io.getstream.chat.android.client.extensions.getCreatedAtOrNull import io.getstream.chat.android.client.extensions.internal.wasCreatedAfter @@ -967,6 +968,8 @@ public class MessageListController( .filter { it.second >= index } .map { it.first } + val isMessageDelivered = channel?.deliveredReadsOf(message)?.isEmpty() == false + val isMessageFocused = message.id == focusedMessage?.id if (isMessageFocused) removeMessageFocus(message.id) @@ -979,6 +982,7 @@ public class MessageListController( isMine = user.id == currentUser?.id, isInThread = isInThread, isMessageRead = isMessageRead, + isMessageDelivered = isMessageDelivered, deletedMessageVisibility = deletedMessageVisibility, showMessageFooter = shouldShowFooter, messageReadBy = messageReadBy, diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListItemState.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListItemState.kt index 5248362200c..f20c5b37ff6 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListItemState.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/state/messages/list/MessageListItemState.kt @@ -64,6 +64,7 @@ public sealed class HasMessageListItemState : MessageListItemState() { * @param currentUser The currently logged in user. * @param groupPosition The [MessagePosition] of the item inside a group. * @param isMessageRead Whether the message has been read or not. + * @param isMessageDelivered Whether the message has been delivered or not. * @param deletedMessageVisibility The [DeletedMessageVisibility] which determines the visibility of deleted messages in * the UI. * @param focusState The current [MessageFocusState] of the message, used to focus the message in the ui. @@ -81,6 +82,7 @@ public data class MessageItemState( public val currentUser: User? = null, public val groupPosition: List = listOf(MessagePosition.NONE), public val isMessageRead: Boolean = false, + public val isMessageDelivered: Boolean = false, public val deletedMessageVisibility: DeletedMessageVisibility = DeletedMessageVisibility.ALWAYS_HIDDEN, public val focusState: MessageFocusState? = null, public val messageReadBy: List = emptyList(), diff --git a/stream-chat-android-ui-common/src/main/res/values/strings.xml b/stream-chat-android-ui-common/src/main/res/values/strings.xml index 02dbbfb4b50..45783b065ce 100644 --- a/stream-chat-android-ui-common/src/main/res/values/strings.xml +++ b/stream-chat-android-ui-common/src/main/res/values/strings.xml @@ -62,6 +62,7 @@ Message read by %d members Message is pending Message is sent + Message is delivered %d attachments Image attachment Video attachment diff --git a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api index 58b31c21121..9b5118f08f7 100644 --- a/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api +++ b/stream-chat-android-ui-components/api/stream-chat-android-ui-components.api @@ -343,40 +343,41 @@ public final class io/getstream/chat/android/ui/feature/channels/list/ChannelLis } public final class io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle : io/getstream/chat/android/ui/helper/ViewStyle { - public fun (Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIF)V + public fun (Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIF)V public final fun component1 ()Landroid/graphics/drawable/Drawable; public final fun component10 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component11 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component12 ()Landroid/graphics/drawable/Drawable; public final fun component13 ()Landroid/graphics/drawable/Drawable; public final fun component14 ()Landroid/graphics/drawable/Drawable; - public final fun component15 ()I - public final fun component16 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun component17 ()I - public final fun component18 ()Landroid/graphics/drawable/Drawable; + public final fun component15 ()Landroid/graphics/drawable/Drawable; + public final fun component16 ()I + public final fun component17 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component18 ()I public final fun component19 ()Landroid/graphics/drawable/Drawable; public final fun component2 ()Landroid/graphics/drawable/Drawable; - public final fun component20 ()I + public final fun component20 ()Landroid/graphics/drawable/Drawable; public final fun component21 ()I public final fun component22 ()I - public final fun component23 ()Ljava/lang/Integer; - public final fun component24 ()Z + public final fun component23 ()I + public final fun component24 ()Ljava/lang/Integer; public final fun component25 ()Z - public final fun component26 ()I + public final fun component26 ()Z public final fun component27 ()I public final fun component28 ()I public final fun component29 ()I public final fun component3 ()Z public final fun component30 ()I - public final fun component31 ()F + public final fun component31 ()I + public final fun component32 ()F public final fun component4 ()Z public final fun component5 ()Z public final fun component6 ()I public final fun component7 ()I public final fun component8 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component9 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun copy (Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIF)Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIFILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle; + public final fun copy (Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIF)Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ZZZIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;ILio/getstream/chat/android/ui/font/TextStyle;ILandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILjava/lang/Integer;ZZIIIIIFILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle; public fun equals (Ljava/lang/Object;)Z public final fun getBackgroundColor ()I public final fun getBackgroundLayoutColor ()I @@ -387,6 +388,7 @@ public final class io/getstream/chat/android/ui/feature/channels/list/ChannelLis public final fun getEdgeEffectColor ()Ljava/lang/Integer; public final fun getEmptyStateView ()I public final fun getForegroundLayoutColor ()I + public final fun getIndicatorDeliveredIcon ()Landroid/graphics/drawable/Drawable; public final fun getIndicatorPendingSyncIcon ()Landroid/graphics/drawable/Drawable; public final fun getIndicatorReadIcon ()Landroid/graphics/drawable/Drawable; public final fun getIndicatorSentIcon ()Landroid/graphics/drawable/Drawable; @@ -2071,7 +2073,7 @@ public final class io/getstream/chat/android/ui/feature/messages/list/GiphyViewH } public final class io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle : io/getstream/chat/android/ui/helper/ViewStyle { - public fun (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;)V + public fun (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;)V public final fun component1 ()Ljava/lang/Integer; public final fun component10 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component11 ()Lio/getstream/chat/android/ui/font/TextStyle; @@ -2091,46 +2093,48 @@ public final class io/getstream/chat/android/ui/feature/messages/list/MessageLis public final fun component24 ()Landroid/graphics/drawable/Drawable; public final fun component25 ()Landroid/graphics/drawable/Drawable; public final fun component26 ()Landroid/graphics/drawable/Drawable; - public final fun component27 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun component28 ()I - public final fun component29 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component27 ()Landroid/graphics/drawable/Drawable; + public final fun component28 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component29 ()I public final fun component3 ()Ljava/lang/Integer; - public final fun component30 ()Ljava/lang/Integer; - public final fun component31 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun component32 ()Ljava/lang/Integer; - public final fun component33 ()I - public final fun component34 ()F - public final fun component35 ()I - public final fun component36 ()F - public final fun component37 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component30 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component31 ()Ljava/lang/Integer; + public final fun component32 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component33 ()Ljava/lang/Integer; + public final fun component34 ()I + public final fun component35 ()F + public final fun component36 ()I + public final fun component37 ()F public final fun component38 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component39 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component4 ()Ljava/lang/Integer; - public final fun component40 ()Landroid/graphics/drawable/Drawable; - public final fun component41 ()I + public final fun component40 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component41 ()Landroid/graphics/drawable/Drawable; public final fun component42 ()I public final fun component43 ()I - public final fun component44 ()F + public final fun component44 ()I public final fun component45 ()F - public final fun component46 ()Z - public final fun component47 ()Landroid/graphics/drawable/Drawable; + public final fun component46 ()F + public final fun component47 ()Z public final fun component48 ()Landroid/graphics/drawable/Drawable; - public final fun component49 ()I + public final fun component49 ()Landroid/graphics/drawable/Drawable; public final fun component5 ()I public final fun component50 ()I public final fun component51 ()I - public final fun component52 ()Lio/getstream/chat/android/ui/font/TextStyle; + public final fun component52 ()I + public final fun component53 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component6 ()I public final fun component7 ()I public final fun component8 ()Lio/getstream/chat/android/ui/font/TextStyle; public final fun component9 ()Lio/getstream/chat/android/ui/font/TextStyle; - public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;)Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;IILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle; + public final fun copy (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;)Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;IIILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/view/ViewReactionsViewStyle;Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;Lio/getstream/chat/android/ui/font/TextStyle;ILio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;Lio/getstream/chat/android/ui/font/TextStyle;Ljava/lang/Integer;IFIFLio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Lio/getstream/chat/android/ui/font/TextStyle;Landroid/graphics/drawable/Drawable;IIIFFZLandroid/graphics/drawable/Drawable;Landroid/graphics/drawable/Drawable;IIILio/getstream/chat/android/ui/font/TextStyle;IILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle; public fun equals (Ljava/lang/Object;)Z public final fun getDateSeparatorBackgroundColor ()I public final fun getEditReactionsViewStyle ()Lio/getstream/chat/android/ui/feature/messages/list/reactions/edit/EditReactionsViewStyle; public final fun getIconBannedMessage ()Landroid/graphics/drawable/Drawable; public final fun getIconFailedMessage ()Landroid/graphics/drawable/Drawable; + public final fun getIconIndicatorDelivered ()Landroid/graphics/drawable/Drawable; public final fun getIconIndicatorPendingSync ()Landroid/graphics/drawable/Drawable; public final fun getIconIndicatorRead ()Landroid/graphics/drawable/Drawable; public final fun getIconIndicatorSent ()Landroid/graphics/drawable/Drawable; @@ -2878,8 +2882,8 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me } public final class io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem : io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem { - public fun (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZ)V - public synthetic fun (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZZ)V + public synthetic fun (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/models/Message; public final fun component2 ()Ljava/util/List; public final fun component3 ()Z @@ -2888,8 +2892,9 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me public final fun component6 ()Z public final fun component7 ()Z public final fun component8 ()Z - public final fun copy (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZ)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem; - public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem;Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem; + public final fun component9 ()Z + public final fun copy (Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZZ)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem; + public static synthetic fun copy$default (Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem;Lio/getstream/chat/android/models/Message;Ljava/util/List;ZLjava/util/List;ZZZZZILjava/lang/Object;)Lio/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem$MessageItem; public fun equals (Ljava/lang/Object;)Z public final fun getMessage ()Lio/getstream/chat/android/models/Message; public final fun getMessageReadBy ()Ljava/util/List; @@ -2897,6 +2902,7 @@ public final class io/getstream/chat/android/ui/feature/messages/list/adapter/Me public final fun getShowMessageFooter ()Z public final fun getShowOriginalText ()Z public fun hashCode ()I + public final fun isMessageDelivered ()Z public final fun isMessageRead ()Z public final fun isMine ()Z public final fun isTheirs ()Z diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle.kt index 5bf394b6366..716fba51519 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/ChannelListViewStyle.kt @@ -50,6 +50,7 @@ import io.getstream.chat.android.ui.utils.extensions.use * @property lastMessageText Appearance for last message text, displayed in [ChannelViewHolder]. * @property lastMessageDateText Appearance for last message date text displayed in [ChannelViewHolder]. * @property indicatorSentIcon Icon for indicating message sent status in [ChannelViewHolder]. Default value is [R.drawable.stream_ui_ic_check_single]. + * @property indicatorDeliveredIcon Icon for indicating message delivered status in [ChannelViewHolder]. Default value is [R.drawable.stream_ui_ic_check_double_grey]. * @property indicatorReadIcon Icon for indicating message read status in [ChannelViewHolder]. Default value is [R.drawable.stream_ui_ic_check_double]. * @property indicatorPendingSyncIcon Icon for indicating sync pending status in [ChannelViewHolder]. Default value is [R.drawable.stream_ui_ic_clock]. * @property foregroundLayoutColor Foreground color for [ChannelViewHolder]. Default value is [R.color.stream_ui_white_snow]. @@ -83,6 +84,7 @@ public data class ChannelListViewStyle( public val lastMessageText: TextStyle, public val lastMessageDateText: TextStyle, public val indicatorSentIcon: Drawable, + public val indicatorDeliveredIcon: Drawable, public val indicatorReadIcon: Drawable, public val indicatorPendingSyncIcon: Drawable, @ColorInt public val foregroundLayoutColor: Int, @@ -232,6 +234,9 @@ public data class ChannelListViewStyle( val indicatorSentIcon = a.getDrawable(R.styleable.ChannelListView_streamUiIndicatorSentIcon) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_single)!! + val indicatorDeliveredIcon = a.getDrawable(R.styleable.ChannelListView_streamUiIndicatorDeliveredIcon) + ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_double_grey)!! + val indicatorReadIcon = a.getDrawable(R.styleable.ChannelListView_streamUiIndicatorReadIcon) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_double)!! @@ -339,6 +344,7 @@ public data class ChannelListViewStyle( lastMessageText = lastMessageText, lastMessageDateText = lastMessageDateText, indicatorSentIcon = indicatorSentIcon, + indicatorDeliveredIcon = indicatorDeliveredIcon, indicatorReadIcon = indicatorReadIcon, indicatorPendingSyncIcon = indicatorPendingSyncIcon, foregroundLayoutColor = foregroundLayoutColor, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/adapter/viewholder/internal/ChannelViewHolder.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/adapter/viewholder/internal/ChannelViewHolder.kt index 89b823ce93a..cf59e8c8035 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/adapter/viewholder/internal/ChannelViewHolder.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/channels/list/adapter/viewholder/internal/ChannelViewHolder.kt @@ -25,6 +25,7 @@ import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import io.getstream.chat.android.client.extensions.currentUserUnreadCount +import io.getstream.chat.android.client.extensions.deliveredReadsOf import io.getstream.chat.android.client.extensions.isAnonymousChannel import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.DraftMessage @@ -356,8 +357,15 @@ internal class ChannelViewHolder @JvmOverloads constructor( style.indicatorPendingSyncIcon } else { val lastMessageWasRead = readCount > 0 + val lastMessageWasDelivered = channel.deliveredReadsOf(lastMessage).isNotEmpty() - if (lastMessageWasRead) style.indicatorReadIcon else style.indicatorSentIcon + if (lastMessageWasRead) { + style.indicatorReadIcon + } else if (lastMessageWasDelivered) { + style.indicatorDeliveredIcon + } else { + style.indicatorSentIcon + } } if (readCount > 1 && style.readCountEnabled) { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle.kt index 934d7a92eaf..c4738b46e8a 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/MessageListItemStyle.kt @@ -70,6 +70,7 @@ import io.getstream.chat.android.ui.utils.extensions.getDrawableCompat * @property reactionsViewStyle Style for [ViewReactionsView]. * @property editReactionsViewStyle Style for [EditReactionsView]. * @property iconIndicatorSent Icon for message's sent status. Default value is [R.drawable.stream_ui_ic_check_single]. + * @property iconIndicatorDelivered Icon for message's delivered status. Default value is [R.drawable.stream_ui_ic_check_double_grey]. * @property iconIndicatorRead Icon for message's read status. Default value is [R.drawable.stream_ui_ic_check_double]. * @property iconIndicatorPendingSync Icon for message's pending status. Default value is [R.drawable.stream_ui_ic_clock]. * @property iconOnlyVisibleToYou Icon for message's pending status. Default value is [R.drawable.stream_ui_ic_icon_eye_off]. @@ -119,6 +120,7 @@ public data class MessageListItemStyle( public val reactionsViewStyle: ViewReactionsViewStyle, public val editReactionsViewStyle: EditReactionsViewStyle, public val iconIndicatorSent: Drawable, + public val iconIndicatorDelivered: Drawable, public val iconIndicatorRead: Drawable, public val iconIndicatorPendingSync: Drawable, public val iconOnlyVisibleToYou: Drawable, @@ -519,6 +521,9 @@ public data class MessageListItemStyle( val iconIndicatorSent = attributes.getDrawable( R.styleable.MessageListView_streamUiIconIndicatorSent, ) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_single)!! + val iconIndicatorDelivered = attributes.getDrawable( + R.styleable.MessageListView_streamUiIconIndicatorDelivered, + ) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_double_grey)!! val iconIndicatorRead = attributes.getDrawable( R.styleable.MessageListView_streamUiIconIndicatorRead, ) ?: context.getDrawableCompat(R.drawable.stream_ui_ic_check_double)!! @@ -769,6 +774,7 @@ public data class MessageListItemStyle( reactionsViewStyle = reactionsViewStyle, editReactionsViewStyle = editReactionsViewStyle, iconIndicatorSent = iconIndicatorSent, + iconIndicatorDelivered = iconIndicatorDelivered, iconIndicatorRead = iconIndicatorRead, iconIndicatorPendingSync = iconIndicatorPendingSync, iconOnlyVisibleToYou = iconOnlyVisibleToYou, diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt index 1441ef5cd04..95ad09c6e7b 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/MessageListItem.kt @@ -75,6 +75,7 @@ public sealed class MessageListItem { * @property messageReadBy The list of users that already read the message. * @property isThreadMode True if the message is in a thread mode, otherwise false. * @property isMessageRead True if the message has been read or not. + * @property isMessageDelivered Whether the message has been delivered or not. * @property showMessageFooter True if the message footer should be displayed, otherwise false. * @property isTheirs True if the message is sent by another user, otherwise false. * @property showMessageFooter True if the message footer should be displayed, otherwise false. @@ -88,6 +89,7 @@ public sealed class MessageListItem { val messageReadBy: List = listOf(), val isThreadMode: Boolean = false, val isMessageRead: Boolean = true, + val isMessageDelivered: Boolean = false, val showMessageFooter: Boolean = false, val showOriginalText: Boolean = false, ) : MessageListItem() { diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt index 0718f3afc1e..f1c3a676809 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt @@ -389,8 +389,9 @@ internal class FootnoteDecorator( SyncStatus.SYNC_NEEDED, SyncStatus.AWAITING_ATTACHMENTS, -> itemStyle.iconIndicatorPendingSync - SyncStatus.COMPLETED -> when (data.isMessageRead) { - true -> itemStyle.iconIndicatorRead + SyncStatus.COMPLETED -> when { + data.isMessageRead -> itemStyle.iconIndicatorRead + data.isMessageDelivered -> itemStyle.iconIndicatorDelivered else -> itemStyle.iconIndicatorSent } else -> null diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/extensions/MessageListItem.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/extensions/MessageListItem.kt index 74e5baa883a..d8adf5764a9 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/extensions/MessageListItem.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/utils/extensions/MessageListItem.kt @@ -48,6 +48,7 @@ public fun MessageListItemCommon.toUiMessageListItem(): MessageListItem { messageReadBy = messageReadBy, isThreadMode = isInThread, isMessageRead = isMessageRead, + isMessageDelivered = isMessageDelivered, showMessageFooter = showMessageFooter, showOriginalText = showOriginalText, ) diff --git a/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_check_double_grey.xml b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_check_double_grey.xml new file mode 100644 index 00000000000..5ab6ebd9f76 --- /dev/null +++ b/stream-chat-android-ui-components/src/main/res/drawable/stream_ui_ic_check_double_grey.xml @@ -0,0 +1,44 @@ + + + + + + + + diff --git a/stream-chat-android-ui-components/src/main/res/values/attrs_channel_list_view.xml b/stream-chat-android-ui-components/src/main/res/values/attrs_channel_list_view.xml index 33c3ce3f70e..39dab2ed1d2 100644 --- a/stream-chat-android-ui-components/src/main/res/values/attrs_channel_list_view.xml +++ b/stream-chat-android-ui-components/src/main/res/values/attrs_channel_list_view.xml @@ -87,7 +87,10 @@ + + + diff --git a/stream-chat-android-ui-components/src/main/res/values/attrs_message_list_view.xml b/stream-chat-android-ui-components/src/main/res/values/attrs_message_list_view.xml index c5a3ae4997e..57dc33a4383 100644 --- a/stream-chat-android-ui-components/src/main/res/values/attrs_message_list_view.xml +++ b/stream-chat-android-ui-components/src/main/res/values/attrs_message_list_view.xml @@ -313,6 +313,7 @@ + diff --git a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/Mother.kt b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/Mother.kt index 0e7ba6a4e53..663af903939 100644 --- a/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/Mother.kt +++ b/stream-chat-android-ui-components/src/test/kotlin/io/getstream/chat/android/ui/Mother.kt @@ -250,6 +250,7 @@ public fun randomMessageListItemStyle( reactionsViewStyle: ViewReactionsViewStyle = randomViewReactionsViewStyle(), editReactionsViewStyle: EditReactionsViewStyle = randomEditReactionsViewStyle(), iconIndicatorSent: Drawable = mock(), + iconIndicatorDelivered: Drawable = mock(), iconIndicatorRead: Drawable = mock(), iconIndicatorPendingSync: Drawable = mock(), iconOnlyVisibleToYou: Drawable = mock(), @@ -301,6 +302,7 @@ public fun randomMessageListItemStyle( reactionsViewStyle = reactionsViewStyle, editReactionsViewStyle = editReactionsViewStyle, iconIndicatorSent = iconIndicatorSent, + iconIndicatorDelivered = iconIndicatorDelivered, iconIndicatorRead = iconIndicatorRead, iconIndicatorPendingSync = iconIndicatorPendingSync, iconOnlyVisibleToYou = iconOnlyVisibleToYou, From 4c22d97d7ac8e3df7e6cbc5b5bed4e9c76781398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 16:29:43 +0000 Subject: [PATCH 29/58] Fix flaky test --- .../utils/observable/SubscriptionImplTest.kt | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/observable/SubscriptionImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/observable/SubscriptionImplTest.kt index 111a994ac7d..c9a1a2bac8a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/observable/SubscriptionImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/utils/observable/SubscriptionImplTest.kt @@ -18,8 +18,8 @@ package io.getstream.chat.android.client.utils.observable import io.getstream.chat.android.client.ChatEventListener import io.getstream.chat.android.client.events.ChatEvent -import org.amshove.kluent.internal.assertEquals import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.assertThrows import org.mockito.kotlin.any @@ -27,7 +27,8 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever -import java.util.concurrent.CountDownLatch +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit internal class SubscriptionImplTest { @@ -143,33 +144,40 @@ internal class SubscriptionImplTest { @Test fun `onNext should not call listener if disposed concurrently`() { - val latch = CountDownLatch(1) + val gate = CompletableFuture() // blocks the filter + val filterEntered = CompletableFuture() // signals we are inside the filter + val mockListener = mock>() - val subscription = SubscriptionImpl(filter = { - latch.await() // Introduce a pause in the filter - true - }, listener = mockListener) + val subscription = SubscriptionImpl( + filter = { + filterEntered.complete(Unit) // tell the test we are here + gate.get() // wait until the test lets us go + true + }, + listener = mockListener, + ) val event = mock() val exceptions = mutableListOf() - val onNextThread = Thread { + val onNextFuture = CompletableFuture.runAsync { try { subscription.onNext(event) } catch (e: Throwable) { exceptions.add(e) } } - val disposerThread = Thread { - subscription.dispose() - latch.countDown() // Release the latch to allow the filter to continue - } - onNextThread.start() - disposerThread.start() - onNextThread.join() - disposerThread.join() - assertEquals("Expected no exceptions", 0, exceptions.size) + // Wait until the filter is entered (ensures onNext is truly paused) + filterEntered.get(1, TimeUnit.SECONDS) + + subscription.dispose() // Dispose from the test thread – this is the concurrent part + + gate.complete(Unit) // Unblock the filter so onNext can finish its execution + + // Verify the outcome (no exception, listener never called) + onNextFuture.get(1, TimeUnit.SECONDS) + assertEquals(0, exceptions.size) verify(mockListener, never()).onEvent(event) } } From 2ea5eaf03f373516fdd507dbeda1158aa844e76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Wed, 29 Oct 2025 17:00:53 +0000 Subject: [PATCH 30/58] Add more tests --- .../parser2/testdata/UserDtoTestData.kt | 13 ++++ .../db/converter/DateConverterTest.kt | 62 ++++++++++++++++++ .../api/stream-chat-android-compose.api | 2 + .../compose/ui/channels/list/ChannelItem.kt | 30 +++++++++ .../channels/MessageReadStatusIcon.kt | 2 +- .../compose/ui/channels/ChannelItemTest.kt | 8 +++ ...ItemTest_last_message_delivered_status.png | Bin 0 -> 25390 bytes ...annelItemTest_last_message_seen_status.png | Bin 25464 -> 25587 bytes ...ssages_MessageListTest_loaded_messages.png | Bin 86287 -> 86272 bytes ...eListTest_loaded_messages_in_dark_mode.png | Bin 88334 -> 88330 bytes .../converter/PrivacySettingsConverterTest.kt | 26 +++++--- 11 files changed, 134 insertions(+), 9 deletions(-) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/db/converter/DateConverterTest.kt create mode 100644 stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_delivered_status.png diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt index f3f1de7ee1f..88c985013e4 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/UserDtoTestData.kt @@ -16,6 +16,7 @@ package io.getstream.chat.android.client.parser2.testdata +import io.getstream.chat.android.client.api2.model.dto.DeliveryReceiptsDto import io.getstream.chat.android.client.api2.model.dto.DeviceDto import io.getstream.chat.android.client.api2.model.dto.DownstreamMuteDto import io.getstream.chat.android.client.api2.model.dto.DownstreamPushPreferenceDto @@ -146,6 +147,9 @@ internal object UserDtoTestData { }, "read_receipts": { "enabled": false + }, + "delivery_receipts": { + "enabled": false } }, "language": "language", @@ -201,6 +205,9 @@ internal object UserDtoTestData { read_receipts = ReadReceiptsDto( enabled = false, ), + delivery_receipts = DeliveryReceiptsDto( + enabled = false, + ), ), language = "language", role = "owner", @@ -282,6 +289,9 @@ internal object UserDtoTestData { }, "read_receipts": { "enabled": false + }, + "delivery_receipts": { + "enabled": false } }, "language": "language", @@ -311,6 +321,9 @@ internal object UserDtoTestData { read_receipts = ReadReceiptsDto( enabled = false, ), + delivery_receipts = DeliveryReceiptsDto( + enabled = false, + ), ), banned = false, devices = listOf(DeviceDto(id = "deviceId", push_provider = "provider", push_provider_name = "provider_name")), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/db/converter/DateConverterTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/db/converter/DateConverterTest.kt new file mode 100644 index 00000000000..e58d153f216 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/db/converter/DateConverterTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2014-2025 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.persistence.db.converter + +import io.getstream.chat.android.randomLong +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import java.util.Date + +internal class DateConverterTest { + + private val sut = DateConverter() + + @Test + fun `fromDb with a valid non null Long`() { + val timestamp = randomLong() + val date = Date(timestamp) + + val actual = sut.fromDb(timestamp) + + assertEquals(date, actual) + } + + @Test + fun `toDb with a valid non null Date`() { + val timestamp = randomLong() + val date = Date(timestamp) + + val actual = sut.toDb(date) + + assertEquals(timestamp, actual) + } + + @Test + fun `fromDb with a null Long`() { + val actual = sut.fromDb(null) + + assertNull(actual) + } + + @Test + fun `toDb with a null Date`() { + val actual = sut.toDb(null) + + assertNull(actual) + } +} diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index c4cd3dcc354..99516a9d1d8 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1262,6 +1262,7 @@ public final class io/getstream/chat/android/compose/ui/channels/list/Composable public static field lambda-4 Lkotlin/jvm/functions/Function2; public static field lambda-5 Lkotlin/jvm/functions/Function2; public static field lambda-6 Lkotlin/jvm/functions/Function2; + public static field lambda-7 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -1269,6 +1270,7 @@ public final class io/getstream/chat/android/compose/ui/channels/list/Composable public final fun getLambda-4$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelListKt { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt index a3a3b7d76c6..87d3f6226de 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt @@ -14,6 +14,8 @@ * limitations under the License. */ +@file:Suppress("TooManyFunctions") + package io.getstream.chat.android.compose.ui.channels.list import androidx.compose.foundation.ExperimentalFoundationApi @@ -46,6 +48,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.getstream.chat.android.client.extensions.currentUserUnreadCount +import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.compose.R import io.getstream.chat.android.compose.state.channels.list.ItemState import io.getstream.chat.android.compose.ui.components.Timestamp @@ -58,6 +61,7 @@ import io.getstream.chat.android.models.User import io.getstream.chat.android.previewdata.PreviewChannelData import io.getstream.chat.android.previewdata.PreviewChannelUserRead import io.getstream.chat.android.previewdata.PreviewUserData +import java.util.Date /** * The basic channel item, that shows the channel in a list and exposes single and long click actions. @@ -396,6 +400,32 @@ internal fun ChannelItemLastMessageSentStatus() { ) } +@Preview(showBackground = true) +@Composable +private fun ChannelItemLastMessageDeliveredStatusPreview() { + ChatTheme { + ChannelItemLastMessageDeliveredStatus() + } +} + +@Composable +internal fun ChannelItemLastMessageDeliveredStatus() { + ChannelItem( + currentUser = PreviewUserData.user1, + channel = PreviewChannelData.channelWithMessages.copy( + messages = PreviewChannelData.channelWithMessages.messages.map { message -> + message.copy(user = PreviewUserData.user1) + }, + read = listOf( + PreviewChannelUserRead.channelUserRead2.copy( + lastRead = NEVER, + lastDeliveredAt = Date(), + ), + ), + ), + ) +} + @Preview(showBackground = true) @Composable private fun ChannelItemLastMessageSeenStatusPreview() { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt index f96952acbb2..204ce0153c8 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt @@ -172,7 +172,7 @@ private fun IsDeliveredIcon(modifier: Modifier) { contentDescription = stringResource( R.string.stream_ui_message_list_semantics_message_status_delivered, ), - tint = ChatTheme.colors.disabled, + tint = ChatTheme.colors.textLowEmphasis, ) } diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt index 0a93c3c95c7..50a8a563273 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt @@ -20,6 +20,7 @@ import app.cash.paparazzi.DeviceConfig import app.cash.paparazzi.Paparazzi import io.getstream.chat.android.compose.ui.PaparazziComposeTest import io.getstream.chat.android.compose.ui.channels.list.ChannelItemDraftMessage +import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageDeliveredStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageSeenStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageSentStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemMuted @@ -61,6 +62,13 @@ internal class ChannelItemTest : PaparazziComposeTest { } } + @Test + fun `last message delivered status`() { + snapshotWithDarkMode { + ChannelItemLastMessageDeliveredStatus() + } + } + @Test fun `last message seen status`() { snapshotWithDarkMode { diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_delivered_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_delivered_status.png new file mode 100644 index 0000000000000000000000000000000000000000..a8209b96da202d22c0af76563cf4148f8f10181c GIT binary patch literal 25390 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 literal 0 HcmV?d00001 diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_seen_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_seen_status.png index 4ceea7e8f61e47cd4fe6fbc99f6ea20e898f7da1..82390220060bc293bdd830e962359bd8eed1748c 100644 GIT binary patch literal 25587 zcmdSBcT`i~w)Y)H;3tTnhzLmgDWIqzUAlmRbm=vKRH+du0YXs_5CK7u5~@h=y+fkX zrPlxh?i@aO4|)1N=j zyVCmhuu$#KV%}kFZJ{gJv+Y-Jy}K+(!z=jdto|4V9%~pjY2N~A4>{Q{?Y!5$KZ^MVWr1`=Zt@iZTaUsx!c_4UCK%&nzda#txLJar-zb>^<3LvZKMf!Tn+%Pq+^8Gg{AW%SB_Q-sylk>!#t|5ZAXH6F3Ch-=jF zpU}By5t!gs;&Dmo!1m!rt=<`6iarj9_|DJ7)cmv?T8K-zVykgGYL~a07M?RT+&E`z zC4FdE`?B7}&GPr;=EFv5&Kq$PnZ!yjYubKh@qWT>;}slMluRw_9;mYsh>n zYYs?T^^E^Yrgo-`&x2ba&>iD=y|-(AT`a{+23qNEG@ruWY%tqLFVP0C}cwA4#WPeYb6``o_Sr-*tf@9 zv-N{uU-Eknsi7kz^7Od>U;{Or5rm&{TuL%o2Au)j#VQp9)VnNCiVb`7@)wI2JXO~c z;dQjnVPa11y!FMD9Lkid51&U_LmG;{Jp43vnF8JUN^5cy;lEMdI0qFrtLZGWKJOl6 zcy=5a62g2+Caz?&Q9LF~lFZ!eCm%ck91#*P?KCVO%_#Eoc{_{VWxFN8s5Oo^sS0a6 z<7+kj1LhJgC-*{QN|MvWoo(?mPT3m88tYT#7C?^`VA=7iXDSBm0X|*_qiX4|sc2RE zeG9?3>o-BKmPC6utA0i_tHc-;^NG-joOic9?$NLfe7oj@@mcP1t(0Jt^swZOlf0!H zZ)O)OF>uww?ODUzOU>HtmW|~MRU^fEmq`tw^R+p4iQ1AP7LfM(8MoR&t=P+FXNwE7 z*at~4Tzkq9rq*%rUBQg^xOOgK$_nN#Wqn+E3a+%0mtv%|+Qk)FBM}zeEj}A9C}Zm& zAHqzUge(70iWOw!(`(W|z(#orwEgW_I0RceX>0*!kevQxW0Xb-9+aY01eJYJDmear zC*4Z&!NBcmnPQvtR8w4@t@6N^KUD@8rwAxZJA4D{slz3EW3Z2rS9@l*;_1cF=jhVW<0Xucb<*_RlVIw}ws z6IaCm|12dgrC5Is+4#lPpw`D??@`r%HMr+nIQ<|nhO6zq(O}{5<;Tc;z?1D6aHCb)RL(?m@^dPwt_p&-%ozo#*~tk?+^W3)78^&GH6$&Nc8Y;weOPr5>GVN6tdp9;?c$Jl zy~9`vhlI<=)+cxVC_1OiYERbAl576acGt9u=gy~_dbi_FBN(}}uGvr6ZMd{55YP~0 zk-gLAbTwPPxVetAW23u%d^6f3gi@GgW6q^_JDvgqUJvH5fz{Dm@MOkRZb{6Qr@ye2 z2?O270T3ajDbOV<9YqVV$MdhgZg!3w?6K2>dNgt zGF*T9+$!+A`o>iGzK)dB&~>TIlIn!enAy@!#RW}8g_E6*7#E>?Ui$IU%c*vs9q7?$BO& z`D31>=)5o5ePtFi=h4`j+?Qe)1bA|kQh}1&#IoAf>1t_h0>YZ=6zti>QgM#|B)Gox z=OZ9gKZ%$2=;71^!ZNq)Q#Ri2?|+CWrKe_{=Uujdi6kx;X6W-NIW}T^#kx-26S$g$ zP2%+y48wE$S`&9FpT_T=$=6H~dTgXb2E5h`cx(%^tpcGYp0r4L!kpL!`B_H<9}T>~ zH(l^9(dH86j%2T#EuqUVUaxqB%GcV^BHa?J8-L~%%dOtF;3>2Gq)t;Y^9FI`VCv7i z>~areJ8#N$$y^Yzvkt-By)Hd=q7Z^0j$3`9oGh{KZ?q!-h~W+J#?<@6L-lWq7I%xnx@^scwv>7#B+=m89V@Q0qpmQ$jzp{ls=J z1z2{RmWy)Ve?JZe(xNgRCDnz>m}bI!f~KuwgL7>iYyV;&8?U2ZRzm7mJXu<@Y>`A7 zx2o%&1&#F6uyNad9!q_*ev{j_1svQ=FY4faV4)GO_h1(sjXc@w|BO()-CG2Xp~wPm zC{_^C>k(!f7Bl;1@t6yz(7JV!Ep*y-jxC9wsL$v_9fz!BVr3LXq&Ki%t*oCiXB#K^ z7*Fx!s&*p`)whCvVQPY2{n-opLU-97KO4B;w$Q1i6UWR9VHR8HV9bpk7FOuJqIX%W z6h_Fm`!F3nbi%7Lu&ApjVIbox9v$YXu{k*&^L+HrTF*-5m`Qo#{3{Wu6;rvf946{^ z*zGlk(Qu!QH<70Ejw!8A39jK(l)`HBZ^I$L68D%E&Iz3pars!9BGQxZ8W>f(Vcqb?0%Xt!jc*r65_8{Ej6wzM3hZ6G=$cn!BQj z;oIsfwk?v*wzHXl)2K;@#RT*hox2gG99E!sFrt!s8Dzu=_8xs)p;U(GVO+=0c?~Zy zh;q2s=^do6|MZ^o>}gh`dtTMJQ<>@{7LG|U68y}QPy1K_KW)dh1u>jt4z73#gvC^f zD;Ci%{qqg8C5ZGhlKV33-r+@!xryY*8;jrQg}uLaP>QvmnuW%Mj`e=HA=W)TN+_jG z6z1I9?~4L~j9>BWTf=hh0QlQ3#`iP6VXP_AKC5q$j&kpxT{<5mUynuJ(L zK3c8F69*z$>A(F@t#tm&+_R2%bRys|rt!4|&4KyDjg+=u7o``O#+$uXLdC&H(()ls zsD3#cf_>Dc9$Ql(y14aTWP&AwtQZUzkmW+CfJd){b`;46iOom9qA@DmWQgE>me%X7 zn>Ep}N1@%#ql9AbC%oUGRJwGE$Fb0P|3aInID`S;pFjrsGBzZL;F{F%W_-O_P0)zl zrDNmmr5jr426ymbvPRvnjfXclNK1x|_#K!yDBFtiD_U683MH0$km=%97BM6!p%3F4 z%q=KDZRO8^O5}PkK&Hy_60T9EUl%JCM}=xt0DoWE zh*C0|_$Fdk(;cFtwv0S`m*z)#9;1(zT8Apen7<7_Jjj!?#iL zAW<&+mr0$sIu9C^?0@xe3p0pzM6TS8+Fj^8;W`K3_D!?Z1XeYL#tL*ui0>P^&soas z*M_C?4nx?PRo(TCZ};q%gFv@T;@JXvIQfz~kLJ}4Lbtza^6LzkUCw37omXE7T{Eix zSbDR&F_L(LyvZYRL8w3**pbOt`QFUO&TYvHL1z6zh6orZ-FH@j(oukTqcZFF?v4$p!> z$qPLDODrZI4KFXi1hj*3?J+!w!ktA>eQVMw_u8)Z1Vd{jd7JyCTvvc)Yp_uRfo%L4MIl*1zCbEf0UdL1gsWHo?&T<=T?}^kPC={`g3TD# zF&7Ow9vk(*`sL;&r0Oo6><{uwxYpbNfvVbQ;j(cLJ_G&`eobOC^BTQqxN=NMLU8bj zCn|GR(aAZGglFR|h|Kgbrksuvgwo_@ylZhu`oN^^e>HPzj(nC}!{strVYfa3MwJ=V z8V@}UkD{EkRU zpLH;s#3m<)oCLc5HC&>9U>!9)a7tWxzIhFGV>!iID_f1L@8a*>o~G?Lru{zu6?kNv zT+L#V@0YKUaE`Qjhl-|UN5Y$>Bhp4W@^$m+vZF^@Ii-0}6x}4@I_bOr;!`Ge$zQ;# zasJptT32qVjAa{Sd~LKwO6FH;f@0~bjVW}W)9LWtxQ3i%=P(8-I`;RdD$>h)s2kh3 zme|vwee0wH8jx%L*ma#ITJC^HQzWNM zSQJc`0!|jFWZ~;AT3oB@h?At8pnT84k^b?cl5(4GbGI12jd>i);%d^Bpi;t1q~*;-cc-bPXq?Yu@|FePKlCp?Z8@c^BMCh(V;q$)^p=5ee_Yb zDluoZm~3aWVHL!cmW^cRt}Vohf?jdV_Me}3p4KKeZx_g7VCilxGoqNLI~wt3AMQbv z_|Gdv@TkNqh10<88nK8M>M_b%6`r;pZ6dhZ#9zFsjIU$3WNnC^wx6qQzhAP=Qp8V- zRyMIq7>k*JQv{P{pp>v&)UY%~pD^p}l$9;gP7{juC1nndSNM6iRSLjkRLf7nH84pEi=BYoP+Qct4#!{D3@aAGP87RLg&$j zQ(3nR!c7Cg+kzy{$Z6(~%ub%%1~bx(PrzxH`Su;mNf(-F ziC&b$qb^GNXWlpl5pX=in&#es`Dz%);8^4nc%;|UvQSIb(a{p<<)YK8$E2)l5{}RxE58E6^VjD``1JNutsF-QgbOl6CjoSgAh2 zHKX%W$cCAr3=aaBQ%WGiJ7UH!aWnE~$-SgylQR$M8Lb?KT37cDURGvETs7>#5MFNV z*tTALFTHJF`x@Lhvky04ktxg{a(7DC-hgO3eJ`KVjkaGq z1Vhn@_p2@#Jsf=O%w6Q6dmQ9`t9XPdk?RKkoz6FmuVTW1WT?;NHcN@lyU0}El?=Q0 zSYPzZ@;3KIbRVaEMAJ^q6d?`(UPiWAywt@Q5&SJ_vsfw)=X!?|HPkV)v0=HSG_r zJP!wbn?1ax_+|L#PDdAKW9)K_WgfLS)S&mya%j~ftvnPhI)-wUUoD$#sE#{Zk2Ksu ze6MSf9-lwT3nsC9m0+amwGY6pTh+R%lO@SLy3koT>!Web;0aGQZ1KovLLJ4K)0wi< zU;c?@n^xPc2(`EBH_9;0g37}`3(tBXLwy4|8msDB$)2sxYH zOnjH}G1Cp=A%=jK*IOQWqqtz#J(>@Z-`QO>tXKI- z+ZKjzJ(c)xcTR(tg`sQYN)16lrz=V z*b7Fe{tMO+m;i?k)P8|kpZ1gd6LNQ@QK8NRk0~EhJ`G#uQg$iHMFp^molB{Ol_7uoSyJYYEbwr^!h|e-^*7{0;_cL<|u*`=m z_Ujd`SupK<=bz^vvpO`!iMwQ6vOh=@Y6ySN;?A+f{p^NL($4c>@41`QaQ)KcD|Zh# zoHBf#b#KT>9ujFRPQ_eF5zrHJ>r+1kT7}HLxGJxJ zO!rK^m&F{{sk8GLwhT!9qmrK@k>%u`!CVagF~I}2naL75S7Vu)#?Af8%jh04K`m4F z8%kG0>S86>3Dahz*{>O2-0(fE;@sUEZ^|q?%<~7%I0EsuJ_lE_x@?XLJCfGv>FP=W zFH}&^MjS)i#1*o0->ZYE>M7x8(T7*vejq|Kg5dn~JL+EyWWg>qa_?Q%ZEbEJDbs`?|3!3TGH zq2?2FXsAYjE;L`JuPaO|PzKYyap(u3Vxf-Q{}HBjvM;+`R|*U2$>eYZXC#!Yzhe+p zr>$Uj$@U-W(qnlitHcF;S+-skai@KUHM`~<{0Kr~VyUb^d#+5>+$mc$S2a3QbdLAb zj6zcAbsM#DB|wba9jvbzkkC_W(VjpVPZ%6}NwNVv`<85LQnMgD2b4X$X!Bbu@`%kbQQw*E#kotkqD-9MCf_bQiw81aj8dfd%gUb84~CCRP=q;wL(7KPtv*Gg^1vrk z(4yJ97pNT=LUE|Z=HhFI7)D3JO^gH~qo=1UOEDQ#?LXIKniJlHooRAr-WgvLn2 zq{3&i^aW9;>=c)XOUWO(0#|=-FBb%?g73 zncCg1xkgFgCNA^Xr;FqfH7^M|Nw8N4O6cv@yHD1@-m z)1gi$YEBzWG#Y4))1H_=rM;f!DUfuhcXenQ!wX3_6F}%3KPrrVUaX`_`{)+}HqW>H zWsg;n#o1MMb+Iw1g;P@>lD0>~RxhxNY(|s3I$QbU39OBO)dKeenwdtGUX>4_GvchdL?Ql@2alot6-Or|0G^Mvl-n69r zr%RNQ>Q{-jnH~2XeWMImVY=Do>TGBi1mvxwCsE#zID7k;Cl@=Z{b6W)7yoHDVN8QT zl>dT&z4-0F2pywRIs;;Gb~ z!eyjs(!P4Ll0FgA|7o~*w*YCA0_<>Rb3Ox8QqtTIBG+4_=Yr6`Ohz!GcXZhxTGg|NR3L@(m-OAdQU`Sr)F#_W=$j}UD>pbHtL6vDh&`rDpDFZHSoTq${>fLN6oEF<;++VNL_~RRW zfNzx6;jLaiYTghfJj0A8gXpt3#qdYu<}SfYLmRYt}u=Bde!$HP7-*Giz0irNds@ zr)J}{M?fQd-yC7=N>By{sPLUH2XDWYFzS?KSC&bNt|xtJxUbGC%tCKt1tZ^RL z3-iG7$g1g>WVCfgppWffTKzbwb191CkTKh{+Vo_dA=hE|hV+Wdja-frOOk`g0&r%$ zP!Z)WGhyXsMgyNkXKtSDLkb=7W8$Tn&MW+KCS)l`e~YFU*_e=O70)#I$y<=hlj{Tmm#Ub?KH->5^L;vou7^JizOV0Zc%-dD5;szs_iF>?R*Y@ggwx8{3!nHf> z0^KRyAEi3ax!cNgt7wgM&8Bn)dQX`ZC}qkd)rc+Ai$1G7>*GJ?7JU!%wLs*-iPOZ= z0Qhd>oJ%CIG-GGq!ToN{Lse0v)wQcNS=j^i;@5-VHoaDRA2SJmsxuaG19L*8q z!w4Q3=Z%|{%pOMP*sNzijrCq+jiS5p;Wfdw54fk!^E`2LSq>`xFmG!4XHwVrpaw>U#I zp4L>yR2^F>$zC6R;x2U{i_oj7j4+bg9N2%N z4LuR8I`S%bPlc_$b3v5O2u;0W>@F31W{IFzJ{-d5ttV7^PnXb8@T1!pTo+!Qy4)kS0_Q@C0s1PO7KTIdOcZp;Z(<9(iS20VViw2 zxd%cu7&0Az@RXTuuJ+x8(WM5v#eTmiI!mKq+H!_|i~4c?4VQD}OBZ@RjHcd8neiXr zNZVcjCuUL5(Pt1udp<<_; z09_(>G@c>Y?G|~ccx|g>?E7Tcn2bApP-R2Nf;Spo^|~=xbH9jqxbT3;lk2lqx-g|f zP&iakIkE~rohy2OnT6GHw&_(3IPpwD@Pc7_Y9=JjgMEIt)Y)$Z=h)s3xz7Nx{$+(- z{QkNL30 z4N|TqLGhjfms+XXT&Yyrw6!X)^A@O8Fc}QkK*Kd`?{eEJ$n9%+ttML-yX=0tXiR;k zI4gH`Jl`~Z&nTpldHn-WFf56wi4JpZlD)&ZDkHt7<5*sg^2Kt_FBN;1a5qa@%eQ7% zT8g$1+~>a}eP8`n5bZd2vQ@Le#RhFqXUJv=_Wm{Ar*y+Hf>jY*ml$AL^hSLj(4KKP zR-h*hHYBfKy8|o@Dve` zBHN~K`Sz|i=SJ6TyGfKUPsTmqpm8HtG&mdI(1)3n=nHO@s&T^-Fdk^9%n|W!-LZxN zLX2oR$6z8JBJnMj@l)Z_xLg-4TK~(WOiCb?=h~5hi6ycf>fBVAj-z69UR<6O4r|EO z&`4kxX<2wMRjSALK0uyJ(&sB-!>m6KTLd3bP@l94KvsiOf=NX|mTe`X&gmPeqmm;H zBa(gf+0VH>0=3ppKEK{cP{mIwBpNE03MQ-A$v>$>mU6y%Pa9Oo9@P^W|2A};KfPs4 zUAu56zdKvK$84%*qCCa4rPNMu+>M)Fn!}?_{XkYm(WO0tPwBL+aLGjC$u+rpx#PyO z&3J}GxBZnV&ami4Rbx~eG3a4WdGL6oXi~B(wtRKtNtl5`jK(wHmf+9DdQPsjQ;xLL z_xCLhT4u+~4AqBm{Xhu*&c%2)*z^+S9RDHvgm2V%?8mhGtCYmhcX(Fn0cD1{I}*Kq z!dZT{csp8+3nJj}7MWMPYv&uRV|rvVxyJcoz3*P>l1uPmAM}AC`S%^B&=?vNtZ*n z3>?|^Wota3$BY=M$A2h@ylxvX{bNssP1E+Jf^2bb!ZrW3qlLi$7SjXT&jvhrU{&7XWdl;QH<#lO@eRJ{WSQx_C8q z*JL2SlV7J_RG9XzeVwnb321

zQ@M(@_!hf~RJrS^bJ%yuWDSdn<;OW{lLmD=v>*`0>%S;B1;nHOl%f7rzxucK|C4_8Z+h3i z>Q_N!fSUE69sW(%`i~L*v;D6(`iCI)p90!{2x9*xp#A5C|7ic4!uFr-f9>#Z?f)-F z{vSI07bE{`hyMkM?|<{0|2K^MKN#`%lm2sV|2`EFN&jBD{I5*^ude!kw9xJbvVRTf|B0*q^8WtcHvOxTD*HZRz{h{!-u~~v z$zLD%KkH;#)Mib+ceCVs?xx8M-c6J1x|{K!?{3EZ(YqP;6j_INX5w}cW^xdOS&iR$ z4h9hD?gCG(O^%tIYlGQHVH_OO)(qd>>2Pe3*vg34F8LJ1bzMUV#G zz;F@^TgbwYxha~=D97D7|8pF_vmzr7o2!B3dN_I7&T%R?E)5#)SZ6nYzhz%jJpIxkjq34VMWNY+^L^BZ^3Fps_9!cU(%Likwho^cw4wx+jEh>qaMTRm2T|fBq2dO2?KY(cgcju2t=B z%YUoWlrN?84exTY#lY~?mjdm)tiBCkK z^Fq}zHn5h2i&0P{Z%Gb>QqyarD!_v~KW~&Q5*&vz9J=3gIxRc5ab3Ish+0|fuh`DJ z!+{!MUDWyRw^15^o6Ft70(NBCbJFhPLkL_ zFDGw4!)F-Jo=nI5B;(&Qdl4@QM;$)5z#tsGaGMPv_GBbj)a(IZLw77kx~>9o;%s2B%EG}d*3 z$J7Oy{VT27TYgI?!Sx9mHploaSNuE-_k3pFZ-L==zk6)pECb$rqrGDY%qsgTgdTvDk0E?8>+=Htk) zBBLnfs7G%DqGJxF4tm|FL={@qb0Nm<3io0{2=se;090lf3#T8GiG_Pby4W?n1 zPBEX9!eX%cw*m&X6#=j$pj$N4YpB_Awr-7W{1i)RztUd0^AZq5I|UXLV_1LX=xi8o znD%q%z`9sWn4W`d9y5s2!wz*Wiab+~G16DplIe`OE7JLB>+i07<#$v z8gP=vcf|zklay|CMu~L`koeu8qbF_F>UL8Er!9JQ{Z; zgOxk|6Y6TfYxkFAo#mPYg3{EI(tQ|&J{fq%%qc-4) z=oOf*`elTWw3UZmgZ*;b%KVlcW093vy5_2b*mMX2e$9Ti&Y8J=K3~(MQSS5bF)BL<-`vUL1+ZSN z5hKS85?1*B`mF{u0GhU%zC z!P6OY0FXZ-wt_O92N#sJprwVG9(vNt7IKYSzxYa> zaRFo#eqI!BD+74tOGV#LF}#kC5KPP)w&1MaVpv>7+ZY>Z$;9Zg{lxU7Nziq_AFnF_ zlGI2FaW9H}{Hn{1g)k)`W@xwiZfS2!aZkVU)E8F_FVS#8RcsZwj^(M(uT6oWU@448 z>sB(0eFMf9UA8vq*fp%u=z*!3N7V?_f7qSy1JvC^%N>m9CWpNZx5{?B&IQrk`_M`Z zQXf3!${&nmHw`&7DqJN6sZZZ7%=p#^DBwTx)V}yJoDZxLahyfK$(qBP)KA)4!*7`O zw^&Rbv~RT;KaSU%)YNZHxQSTNuHec28^SSH86#Aal+P zgnSkodN6+2wvI(2E%>p|o8*8)?(0E)d?~4w$Zbbt$SYE?#ASW}@pyN|{;IG@ZB}s0 zVnQn1efAoKu<^fRDElbu!Re`TZD9`kKw!bNyIhh8$aD`KQs5u9W{m=VV<_4) zm%B$G2fT)c%?MenRAYsH*?+ILyJgqi9@q?5g`R_l=c=x73OK$D$Vl}|Pi5l{zD6Bq z9TG8gV!C&f$$pvuaspt*!x?Z=rQYJf}IHTL) zyalC`gf*vkXVRhQ~$%I`oX6GEf2eX&7= z!Boi$;lCY4DRiJsC-Y@wswD%ge65Y?QI6bu(+}gsFi^MJu zinVX80Cl=#Wi1rHwpQat#hvjd$Y=3-tA0m6)h-ji?2-wJkN808?hSC~$#r=XmRyf+ zwdc8$Ie@Zyx;&IcmX8+V;;cf}iwg(h6!D_mfjiGs zrM3Y=t9AgWU%CVzn^^gG1tA+Um%kz_9DV_b+?|K<@cpl}fMWkvXVEi=YCK%L*hW2u z9Y4e^BYr&K4f{GYDeL_6Xm`}xZVPwBG@kqL?V7EM){3UE&+_s$Ov5x!;;>WpJ-NbI+W7XSbg)=!=VFo&_k^Tr0Cku49M} z4XEnRb8z@Q^M6zJ*Pgj`K@@-ZDlO@*l1n?Y%RQvI1ApM>RT(esLG9f66p+bQg#%g?FN&8X5foq7$>C`{3uB zGVJM>ieZ2s#fGNCyG_WH#kJLLuvbJ{ACFy%ok|~l6}T;DQj)ix5B8nuvh*J9syJAKJ7xJG zeSl?xmWsY1HrZf9^dw7a{-_ex?zxa#J9}&GYpTm!84mSF|2n7dusoqVjUCFp7?6`Tb@U{?F`6PXb@DBoaxn0`96jkb>6<+JWJ! z#SEE}yjN#b+0{+>?z!v$ba}RaPa1V2)I?+JUlQ^1xMt7+xzjt+73cpi|_ys#ZPa5BxD0}+e zHTZYfMlSZcv`m2Z!lku>XP#_XQFx@yRGD}9ijwUCgd4plgJD{g z^Fi8oTJLqJ*?W@;BGYw~6v&*7qa^M+pM6EBkUW7-PrYGkvK3c<8@0Q$V>eu*a9-5E z#RhpeHO9p3$_i98?gD_|LBDW1F^o3dKSwESU@ld9s;R-fmK}dOT~`dB$d5L=AKXSS z6kVt-=B4&tJXhhk*GRzUo+R)q4d-|Z$jvKh#bN!%F=U6rO3WFr6%3Vu7#DKA9S_K82k+p|=KT#^ z5u99Aw2AjW&A+;wMdB74rdWH4+5Grac4ku?f(w}*(t^tjww*fO@xq1a+P5jboG=Bn zg^idE;kJ{OH7TBbS;WR}SnH~bC0rP>?!x7N;_pT2(1rVE2X0TT0mYE5fOYq*0K5Z% z6Vi^J)k+i3w~@V6o((~Ok6-BoMFu-nkTZuBaEm%_jAqt>lYFk3&NHM#%8U@q%c~KL zce3`IJUA^5=?;v$CNfLz9uA9e3^c~(qdI)b^jm=0_|D>4n)jria5OHQwk;29-jeSh zf<)>f?=#9D$N2AjD8$qe8|)rrn|G^a`^^V$vynKhRRvD{e#6!g&ZwVWt#iXoSYp#w zp+`CkhkXPCw}X}QX#?6MyPCAgsEF?ih%dsgKrQM$8J8x)SGZ)|5xyE}g_6DsT!D+X zrF6OLoFV)@1-QLKs7H{C^)-orG7^Qf8T^Sj5u6{g!jbr71A6pUZ#nh=L7Yw$ZX&zs zxRG75M4uJWSsA0==L1GM81)M%MrDZ@Wz37+s-V4w*T{f`cS&L$+3L7>^*}cPMM2` z(jgZ)kM>7aA55MeXzVx6tw3Dy54)QUS0u@>Z*SDACC$d?HfC{uoa@)|cNNmOD5|;H z=RHBiXz2D9_)&yJ7Ta&M54)9t6=t#YBb|{zmx@-a*N}*ctoJR{_YI;71UfP|zp2#7 z>wR`VU&N*HlGP?xg8+Sd1wo?y)Y|+0G1YGSK_*(W($UH^;sWO-&WzK4kEdLvxUbC~ zuAa+b%4Lp4`n_R2NZIOJUq)|BG-40q4?i`N5NcsBCcA=`w@LcX9=`51Le`tf=)~z5 zA9L0Vk3r;i4=Bw$pVqubM~_FKg+e^KEroh8rL1b-0r|d&8(}`=#E6H}Uf)sXECvMtd zv}Md2f*@zXbocX0dSKhSex7ObX((Ip(G-@~|yIS#zc**(*Gnughb;_W$(bQ;b|_mU67 z`#VXMPn8NS%WR(n-xvPF_HTen*s=nt-MkSxzh6b#jFxd1?W_eJm#=}`* z6358hAl2?egE zPI}Y9etK`oc*jf;YbDI-Arz>T;swfEh28%+>{%0#^lK(# zF>ZIsnHRGIe-@0&t+yVgHIyq*JW3fcuw#aH1^efYw~4DF3v(MXl=4+2<&_#CU$mf` zBegF~4&hFftymc}cUt?|1MzD`9W&=BK3*ktSs1+_?sFRS?PLP_5UxC^;b6AvoGHut zzuGy^s3x~H0owotX$lvCgPbHn15wU zu|{Xa4irCHQ93^yv&>HcNxAH@0t-+Uy-|+(rPN&p&FKApF;kwG77K0auM9ztL4Au1 z13tUaRKqdlq4E*!SiWRV=WlpVq;bw^x)pm+SI0=kaD0ygbLz_8bfb@pTEONu-D}Dg znV#KIp&F9R9eQK_%Vj;)L$fHWmnz~LJM)zvr}!gjBn6A1fz?yau07S`O4P9l2I?KF zJJdt@2=Ikx`!hS4lwh0$Y!GI6&bXRUmFI^_zkyF>0;t0^@4#jpp-kZe!XA zY3NiIo+-_6SyeXml(WRUxLB!%&F`b)rvBsXt_&GlQ0y(0$#MHuoGkAFqXJIfS)#3L ztRuh{n*}H|@TM~Gwy*%Ebm5J9pagK=e+m)CH?d}&!#M@}hBtlPUnVc9+?rr#P=)Ql1 z5~eTff4f2{q%-Ag+iXYy2KW4U;KdX%oVyy=Xp(MCl|D*0nh9toYUcsXcR@7Qj;^t^ z@!ZbOmws0Dt}{yED_;5?_WA{RiNg|r!Y4XRcm3U%cwxMkpqyFK3$uazkL0nHFPkI) zbSb;1e-FEu{evjb_~R8DDgRxt^?0YxTG8Do7|rFb;28zy096?T|K-l3?CMz$Evtw? z#xJ_RU&)4J{sOr-4jJ+V282XrF{_a!epuP-qPytzUDY{@_w5(jcWH_0ohlPe3bnhQ zCSQX>fL!C@xbmx0l^|6J8MHjx#1L|Q988O%K9Ru{E7%e_!Ip2BEoctdQ(Md|a^<&v zWco;6YF+P=9ff?lAlsxO3)pm+Kzq&1fz)`V@@Kh-JCBNnA}e#`FEO86IlX@*D3WwE zAyD&jj8Hy{mc&I7brrmYo4%gEoBpq?3QTOrqr;GM4|I|lfIOCBH+I<7UH{^RV!erC z8r1`~Fkw;5fZi%kpJgi@Y>@W#uNz}SJusOw9f+tu9~!G$w@Z0i)iw3$iXl_=WaCB; z6SER^h@fAG`L74EG%p#9aZ|jd&xwmn{qsQ$Zeu z>O&9LcGO7SWi~>Eav-qYkpblXPsvXLd3-H>O?~MBbumrOTgU&?3Gu8p!w7`LepD{>sT|Y zb(JxLN^LIOZk&EjTWFhu;CEX53{4VrtdMPt8Ei;|<$WYF`|Qd0mo(Zbo6^`_E#ukj zf}~tC4+GR*jL$+T%O?T6?w*}8kd{^MuLH-360M-EpFGnq zhU`@l7h~XsMCDD_iYYUMso05>{?4#4zC!ck*i!Xua?BJ%YtqyxK~sR6W{H_kBTX5% zl~a5wBj6fvR`udpfkxiqOYesC;qwo5gvHXcY%a(J)7M{%zh=yMJ>>V0!0>$!`tkVq z-U~6@Nr7zCk}>yW^Qdfj^1Um7Hb@=#mf@U|AhR1lvTVWbqzaL0hkzgBm-LBbHMMVx z`n^0QX9T+mT&n;w3a?9VU;xv<9K*eqIH5Wx2;P%S17*; z0xR!}%jrQnfF$i&IQFd##H)<9YeF3oIEj5~S4OtHz;{?}<}}&`Zb8socBhhy9DIN@ zXMLTnSs*5l7AeGJ`Ax>>wcmyAi;ilDxkA@*rd z^Rx=SPh>u%+6ve;!PlEJ8}TKJ>zlX!?#_yaRts&O=shr`eGU@7F=;ec!>!}(47Uz{ zR9QZZbtu$*3Fm)tAe`d&5D=>#Um1Fb)q_0>?HZYzV?6m&609Y`?7V5V5Q$zU1GXdq zl!c61g9b`9z!f$BVCP6KsxMrhePG!g!3H)O}tg*Hd(8AtWz~+2uqFi*WMBLLh7G3 zOa)nBfKWf|uDfx-inpUD#CGKure#uJqJAWS6V3RYg?~)&Y~VshEGj=BVMRGoRr<%) zNL&<^6yUTj0mM}ZXNK}_{xhJ(b?ohD-W(~rgcH&za!)a1mg;CAoCw;9Wmm!G7P5^} z(Dbj^T7TqzdCWrqgu7+Ak+h9o_zS=DZiJarjYzcu`@G~s23}b11_TN_!b%AkEcUvM zKHymN8%B~S)1)BBy>zwoZ%LaO?9U!tImiXvsB8*WkV70IPuYEiv5L-`udX zGe1qW8fb90JMm#Ao?rbFjAp@I)R-g6zgu8gmE1Sz|kS206 z#WON@(kwUwFxlCu>yYw9%t>TWUJ@&RFkD(tQKYmOfF~>--~I$qS$>^*{i;_paaH-E zQ->k$V%wmCx+oMDr~KGQaD5q%USAB_eLo33cmL|)ux!MbUodgm`=#y)x!^0QlTE_l zs=MgJeQ_B+7E_yg-KKSo?xp-G@SVr2-TqfiJ5QNC@vwwmd9BM)bhAlRai#|poyEvi z)sj%g&z5Dk9^{}{8ra;bR}TFZTan7fx>=lYUbntF;H0*lZOM1^GbP^&xl#k4_T7~a zLEFFUAcSO8&Y7gWAzF=z&Y=B+<8>W}J>OLs=mQA=CaBaZTj|w} zx}Cp)XYaQTrvT~nMz}`1vVwX4d%s&*LW6Zipb}lL-7v{Fa=0JY9+LX6tiJ%sdc8Vh z2b-lR40X0`iVoDBwxWs+j9*(5%zj0L&+eJ=_v%E5`4`|&5FJnI=-X}d8}{aZ3tKEWqN$};aGUYJxD zu*6YWyn_I4Kse%%dD&CUHn1!xfo~4Lm50DayPsQm1Fjql`MSQh6FVbs(*uB-%1X~! zBfA%r`p_o8A~3mwmwQS`-Jl0^E%V?>?(Vjv@yjK;{T1w4M+d!$@g@29-a3o~Mu^$% z!c7oA`HCw|es#*#ZYhLR+le`u_brPd8G3kc$6?IpbaQEiLiC1v zB(d9*r%1DFv5a24%#kdOb0>6+f88?aNjp6TCI>-FJ!xdo=Cz@)9eQF@!l5xg^pk;6 zTUDl~-aaS=+*VS?Iiss7Kt{X;aDgan5hd>W_5%Rlrn1X=FVf=|21`t0n5w7X`OP*r zky35J#PPV=oYN>NG)nHX1Y3>RZGz=ocusQik~1`S*gu5Jj0d_nOh`HZoqyU46a~;K z&2vm+N6lZ$kzeN=&?*S;5aovPSUe%N`HN&X5h@nh18M9Gcw30_RJ#?RHdJivMA-e& z$lECcsy=B~zy!SO%xL1*x3Csqe%=TL*ah$X9nigx(s6E_0JKu$oBtMzAM@sqQ_TM` z72sRHUsl6eQBMgRhluQ|g4v(F{rncvT&GMN|Bznb_HZs|*#A5E)9Id~_FZFP@E$f* zkCsV|p@b})elzfjBK5gD?9qmB&(pYz8z06BEysOZ-T52T9?X*(eHv$*!3|_%e*+zi z%j!mgY4mg5Ht4wlpAM!z{Oa6BKtf-DCbNlEDuAqbAd0gRY7&>oLs8aiWw3%3@UEP@ z`qstV0ux$8u=-@Th56ZEkrj)_BR+HKL)fc7J3yVxgFG^-rS_=(Z8Emz8CBhisCVi< zRq)0*Ugdj^529ZXB4Ja*2H_~zr)&a=D)#m!C(6|LiK$~mSY#<_$|dN^8|^}AO9Khn zK_Abv+*mYXT>edWcQzMD=D>OGV z=J);8AsJ6d!HQXo`>x>nz8e+9335EM$0*=??6;3!$rMm8#qn3JguyE`vP{2m{X{`; z8gJ6^g}t|as<$~TuU2RQcv;=GX^m;`070i?)SSsAxuA;5W9ux+eg?FzVG#Gtzb3+Vq?#b5mHbBEQ{cTK&?Z`a8y!H_wFfGvy-gnYTz5xTB@9 zJ7a(?BN|Va%}ccKmF53g3iPuhBMA0vy%HGIUQWJe*1A6hr_L zETQE0B8A}`_-P;(HhAv3B)q`(fD!lvTI!n6N$wss%rfW=S`t@ng$GXEEbNu6NG}PC zOyA~93xE3HAeAxok?{Ge(oSRrd9Q^#@(%$I7vhJEyf=`=fY`b(|8Vf)2+e)}QYb*k zYzdzHU7Y?0HSo98+^_WUvg?4O4lr`l?P$$6zCP9wK6=O=h{(qRDL%gzj}s3cl94;~ zTX9H>@!-=z@ey#XpW*$zI0RG#J{=VQSU)!Q_OC;z1WAMN>EgO&r``Nw`7 zbl{Krzjf#L4*dJ-|2xO=Z{7I2dC<~2{;y5MpFZGk^Z7qEj{jsF&i_jj@lPJ`C-wh) zLH--Zf%BLB@%u{pV?q6I4CH@$9R7b}{s#xcf8l`p)AjU!Zeu>y&}8zC8E1VydvNaS M+%&vVu4y0oSG9M@UH||9 literal 25464 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 diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.messages_MessageListTest_loaded_messages_in_dark_mode.png index 8e158b4d67c2931a840ee7b3df2e3e77442ca7bd..ffd31ca7350bd151a5aaccff648703fe04b3b203 100644 GIT binary patch delta 34806 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%0Auk*^a%6}bufKD3(cysvcK+CO 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 Date: Thu, 30 Oct 2025 08:51:00 +0000 Subject: [PATCH 31/58] Feat: Add `Channel.readsOf()` extension function Adds a `readsOf(message: Message)` extension function to the `Channel` class. This function returns a list of `ChannelUserRead` objects for users who have read the given message, excluding the message sender. Also includes: - Adding corresponding unit tests for the new function. - Renaming `ChannelExtensionsTests.kt` to `ChannelExtensionTest.kt`. - Migrating existing tests from `kluent` to `JUnit Jupiter` assertions. --- .../api/stream-chat-android-client.api | 1 + .../client/extensions/ChannelExtension.kt | 17 +++++ ...nsionsTests.kt => ChannelExtensionTest.kt} | 72 ++++++++++++++----- 3 files changed, 74 insertions(+), 16 deletions(-) rename stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/{ChannelExtensionsTests.kt => ChannelExtensionTest.kt} (65%) 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 ea78c5dcb21..f492300ddf3 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -2664,6 +2664,7 @@ public final class io/getstream/chat/android/client/extensions/ChannelExtensionK public static final fun isArchive (Lio/getstream/chat/android/models/Channel;)Z public static final fun isMutedFor (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/User;)Z public static final fun isPinned (Lio/getstream/chat/android/models/Channel;)Z + public static final fun readsOf (Lio/getstream/chat/android/models/Channel;Lio/getstream/chat/android/models/Message;)Ljava/util/List; public static final fun syncUnreadCountWithReads (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Lio/getstream/chat/android/models/Channel; public static synthetic fun syncUnreadCountWithReads$default (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/chat/android/models/Channel; public static final fun userRead (Lio/getstream/chat/android/models/Channel;Ljava/lang/String;)Lio/getstream/chat/android/models/ChannelUserRead; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt index 08d435900ed..574eef4596b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt @@ -130,6 +130,23 @@ public fun Channel.syncUnreadCountWithReads( public fun Channel.userRead(userId: UserId): ChannelUserRead? = read.firstOrNull { read -> read.user.id == userId } +/** + * Returns a list of [ChannelUserRead] objects representing which ones have + * read the given [message]. + * + * A message is considered read by a user if: + * - The user is not the sender of the message + * - The user has read the message + * + * @param message The [Message] object for which to find read reads. + * @return A list of [ChannelUserRead] objects representing users who have read the message + */ +public fun Channel.readsOf(message: Message): List = + read.filter { read -> + read.user.id != message.user.id && + read.lastRead > message.getCreatedAtOrThrow() + } + /** * Returns a list of [ChannelUserRead] objects representing which ones have * delivered the given [message]. diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionsTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionTest.kt similarity index 65% rename from stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionsTests.kt rename to stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionTest.kt index cab3d961813..2372cb96d39 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionsTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/extensions/ChannelExtensionTest.kt @@ -26,46 +26,48 @@ import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser -import org.amshove.kluent.shouldBeEqualTo import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import java.util.Date -internal class ChannelExtensionsTests { +internal class ChannelExtensionTest { @Test fun `isAnonymousChannel should return true for anonymous channel`() { val anonymousChannel = randomChannel(id = "!members-12345") - anonymousChannel.isAnonymousChannel() shouldBeEqualTo true + assertTrue(anonymousChannel.isAnonymousChannel()) } @Test fun `isAnonymousChannel should return false for non-anonymous channel`() { val channel = randomChannel(id = "messaging:12345") - channel.isAnonymousChannel() shouldBeEqualTo false + assertFalse(channel.isAnonymousChannel()) } @Test fun `isPinned should return true if channel is pinned`() { val pinnedChannel = randomChannel(membership = randomMember(pinnedAt = randomDate())) - pinnedChannel.isPinned() shouldBeEqualTo true + assertTrue(pinnedChannel.isPinned()) } @Test fun `isPinned should return false if channel is not pinned`() { val channel = randomChannel(membership = randomMember(pinnedAt = null)) - channel.isPinned() shouldBeEqualTo false + assertFalse(channel.isPinned()) } @Test fun `isArchive should return true if channel is archived`() { val archivedChannel = randomChannel(membership = randomMember(archivedAt = randomDate())) - archivedChannel.isArchive() shouldBeEqualTo true + assertTrue(archivedChannel.isArchive()) } @Test fun `isArchive should return false if channel is not archived`() { val channel = randomChannel(membership = randomMember(archivedAt = null)) - channel.isArchive() shouldBeEqualTo false + assertFalse(channel.isArchive()) } @Test @@ -73,7 +75,7 @@ internal class ChannelExtensionsTests { val channelId = randomCID() val channel = randomChannel(id = channelId) val mutedUser = randomUser(channelMutes = listOf(randomChannelMute(channel = channel))) - channel.isMutedFor(mutedUser) shouldBeEqualTo true + assertTrue(channel.isMutedFor(mutedUser)) } @Test @@ -81,7 +83,7 @@ internal class ChannelExtensionsTests { val channelId = randomCID() val channel = randomChannel(id = channelId) val user = randomUser() - channel.isMutedFor(user) shouldBeEqualTo false + assertFalse(channel.isMutedFor(user)) } @Test @@ -94,8 +96,8 @@ internal class ChannelExtensionsTests { ) val channel = randomChannel(members = members) val users = channel.getUsersExcludingCurrent(currentUser) - users.size shouldBeEqualTo 1 - users.first() shouldBeEqualTo otherUser + assertEquals(1, users.size) + assertEquals(otherUser, users.first()) } @Test @@ -115,7 +117,7 @@ internal class ChannelExtensionsTests { ), ) val channel = randomChannel(messages = messages) - channel.countUnreadMentionsForUser(user) shouldBeEqualTo 2 + assertEquals(2, channel.countUnreadMentionsForUser(user)) } @Test @@ -140,7 +142,7 @@ internal class ChannelExtensionsTests { ) val channelRead = randomChannelUserRead(user = user, lastRead = lastReadDate) val channel = randomChannel(messages = messages, read = listOf(channelRead)) - channel.countUnreadMentionsForUser(user) shouldBeEqualTo 1 + assertEquals(1, channel.countUnreadMentionsForUser(user)) } @Test @@ -149,7 +151,7 @@ internal class ChannelExtensionsTests { val unreadMessages = positiveRandomInt() val channelRead = randomChannelUserRead(user = randomUser(id = currentUserId), unreadMessages = unreadMessages) val channel = randomChannel(read = listOf(channelRead)) - channel.currentUserUnreadCount(currentUserId) shouldBeEqualTo unreadMessages + assertEquals(unreadMessages, channel.currentUserUnreadCount(currentUserId)) } @Test @@ -158,6 +160,44 @@ internal class ChannelExtensionsTests { val unreadMessages = positiveRandomInt() val channelRead = randomChannelUserRead(user = randomUser(id = currentUserId), unreadMessages = unreadMessages) val channel = randomChannel(read = listOf(channelRead)) - channel.syncUnreadCountWithReads(currentUserId).unreadCount shouldBeEqualTo unreadMessages + assertEquals(unreadMessages, channel.syncUnreadCountWithReads(currentUserId).unreadCount) + } + + @Test + fun `readsOf should return correct list of ChannelUserRead who have read the message`() { + val createdAt = randomDate() + val messageUser = randomUser() + val otherUser1 = randomUser() + val otherUser2 = randomUser() + val lastRead = Date(createdAt.time + 1000) // After message creation time + val read1 = randomChannelUserRead(user = otherUser1, lastRead = lastRead) + val read2 = randomChannelUserRead(user = otherUser2, lastRead = lastRead) + val read3 = randomChannelUserRead(user = messageUser, lastRead = lastRead) + val channel = randomChannel(read = listOf(read1, read2, read3)) + val message = randomMessage(user = messageUser, createdAt = createdAt) + + val actual = channel.readsOf(message) + + assertEquals(2, actual.size) + assertEquals(listOf(read1, read2), actual) + } + + @Test + fun `deliveredReadsOf should return correct list of ChannelUserRead who have delivered the message`() { + val createdAt = randomDate() + val messageUser = randomUser() + val otherUser1 = randomUser() + val otherUser2 = randomUser() + val lastDelivered = Date(createdAt.time + 1000) // After message creation time + val delivered1 = randomChannelUserRead(user = otherUser1, lastDeliveredAt = lastDelivered) + val delivered2 = randomChannelUserRead(user = otherUser2, lastDeliveredAt = lastDelivered) + val delivered3 = randomChannelUserRead(user = messageUser, lastDeliveredAt = lastDelivered) + val channel = randomChannel(read = listOf(delivered1, delivered2, delivered3)) + val message = randomMessage(user = messageUser, createdAt = createdAt) + + val actual = channel.deliveredReadsOf(message) + + assertEquals(2, actual.size) + assertEquals(listOf(delivered1, delivered2), actual) } } From 645b13aba1510ce8a02b14ab3ff6c2ccf62cbab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 08:57:29 +0000 Subject: [PATCH 32/58] Add pending status indicator snapshot test --- .../api/stream-chat-android-compose.api | 2 ++ .../compose/ui/channels/list/ChannelItem.kt | 21 ++++++++++++++++++ .../compose/ui/channels/ChannelItemTest.kt | 8 +++++++ ...elItemTest_last_message_pending_status.png | Bin 0 -> 25740 bytes 4 files changed, 31 insertions(+) create mode 100644 stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_pending_status.png diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 99516a9d1d8..3475d19eb5c 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -1263,6 +1263,7 @@ public final class io/getstream/chat/android/compose/ui/channels/list/Composable public static field lambda-5 Lkotlin/jvm/functions/Function2; public static field lambda-6 Lkotlin/jvm/functions/Function2; public static field lambda-7 Lkotlin/jvm/functions/Function2; + public static field lambda-8 Lkotlin/jvm/functions/Function2; public fun ()V public final fun getLambda-1$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-2$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; @@ -1271,6 +1272,7 @@ public final class io/getstream/chat/android/compose/ui/channels/list/Composable public final fun getLambda-5$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-6$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda-7$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda-8$stream_chat_android_compose_release ()Lkotlin/jvm/functions/Function2; } public final class io/getstream/chat/android/compose/ui/channels/list/ComposableSingletons$ChannelListKt { diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt index 87d3f6226de..7e5ce091379 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt @@ -57,6 +57,7 @@ import io.getstream.chat.android.compose.ui.theme.ChatTheme import io.getstream.chat.android.compose.ui.util.getLastMessage import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.DraftMessage +import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User import io.getstream.chat.android.previewdata.PreviewChannelData import io.getstream.chat.android.previewdata.PreviewChannelUserRead @@ -380,6 +381,26 @@ internal fun ChannelItemUnreadMessages() { ) } +@Preview(showBackground = true) +@Composable +private fun ChannelItemLastMessagePendingStatusPreview() { + ChatTheme { + ChannelItemLastMessagePendingStatus() + } +} + +@Composable +internal fun ChannelItemLastMessagePendingStatus() { + ChannelItem( + currentUser = PreviewUserData.user1, + channel = PreviewChannelData.channelWithMessages.copy( + messages = PreviewChannelData.channelWithMessages.messages.map { message -> + message.copy(user = PreviewUserData.user1, syncStatus = SyncStatus.SYNC_NEEDED) + }, + ), + ) +} + @Preview(showBackground = true) @Composable private fun ChannelItemLastMessageSentStatusPreview() { diff --git a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt index 50a8a563273..76056fe3394 100644 --- a/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt +++ b/stream-chat-android-compose/src/test/kotlin/io/getstream/chat/android/compose/ui/channels/ChannelItemTest.kt @@ -21,6 +21,7 @@ import app.cash.paparazzi.Paparazzi import io.getstream.chat.android.compose.ui.PaparazziComposeTest import io.getstream.chat.android.compose.ui.channels.list.ChannelItemDraftMessage import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageDeliveredStatus +import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessagePendingStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageSeenStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemLastMessageSentStatus import io.getstream.chat.android.compose.ui.channels.list.ChannelItemMuted @@ -55,6 +56,13 @@ internal class ChannelItemTest : PaparazziComposeTest { } } + @Test + fun `last message pending status`() { + snapshotWithDarkMode { + ChannelItemLastMessagePendingStatus() + } + } + @Test fun `last message sent status`() { snapshotWithDarkMode { diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_pending_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_pending_status.png new file mode 100644 index 0000000000000000000000000000000000000000..4af9d72f472264a20c6da4a7e20a5c336b696010 GIT binary patch literal 25740 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 =`Kk2%r;=s2%_#IS^Fd4UEy@wIW?zPJTX2^`Qd+ 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 literal 0 HcmV?d00001 From 0f1eda652fdc4d5f944ec29c7815a09b3d281e40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 09:28:21 +0000 Subject: [PATCH 33/58] Add unit test for MessageReceiptRepository instantiation --- .../repository/ChatClientRepositoryTest.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt index b5adf17e257..208ae1bbe55 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/persistence/repository/ChatClientRepositoryTest.kt @@ -16,13 +16,28 @@ package io.getstream.chat.android.client.persistence.repository +import io.getstream.chat.android.client.persistence.db.ChatClientDatabase +import io.getstream.chat.android.client.persistence.db.dao.MessageReceiptDao import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.Mockito.mock +import org.junit.jupiter.api.Assertions.assertNotNull +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.mockito.kotlin.verifyBlocking internal class ChatClientRepositoryTest { + @Test + fun `should instantiate from database`() { + val mockDatabase = mock { + on { messageReceiptDao() } doReturn mock() + } + + val actual = ChatClientRepository.from(mockDatabase) + + assertNotNull(actual) + } + @Test fun `should clear repositories`() = runTest { val fixture = Fixture() From 8656682c29561693dc5f99c2be27235b4712c39e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 11:29:38 +0000 Subject: [PATCH 34/58] Fix the message status indicator paddings --- .../api/stream-chat-android-compose.api | 9 ++++++--- .../compose/ui/channels/list/ChannelItem.kt | 2 +- .../channels/MessageReadStatusIcon.kt | 2 +- .../ui/components/messages/MessageFooter.kt | 1 + .../compose/ui/theme/ChatComponentFactory.kt | 4 ++-- .../ui/theme/ChatComponentFactoryParams.kt | 2 ++ ...ItemTest_last_message_delivered_status.png | Bin 25390 -> 25392 bytes ...elItemTest_last_message_pending_status.png | Bin 25740 -> 25762 bytes ...annelItemTest_last_message_seen_status.png | Bin 25587 -> 25600 bytes ...annelItemTest_last_message_sent_status.png | Bin 25254 -> 25254 bytes 10 files changed, 13 insertions(+), 7 deletions(-) diff --git a/stream-chat-android-compose/api/stream-chat-android-compose.api b/stream-chat-android-compose/api/stream-chat-android-compose.api index 3475d19eb5c..b15fe63f504 100644 --- a/stream-chat-android-compose/api/stream-chat-android-compose.api +++ b/stream-chat-android-compose/api/stream-chat-android-compose.api @@ -3530,12 +3530,15 @@ public final class io/getstream/chat/android/compose/ui/theme/MessageDateSeparat public final class io/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams { public static final field $stable I - public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;)V + public fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;)V + public synthetic fun (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; - public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;)Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams; - public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams; + public final fun component2 ()Landroidx/compose/ui/Modifier; + public final fun copy (Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;)Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams; + public static synthetic fun copy$default (Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams;Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState;Landroidx/compose/ui/Modifier;ILjava/lang/Object;)Lio/getstream/chat/android/compose/ui/theme/MessageFooterStatusIndicatorParams; public fun equals (Ljava/lang/Object;)Z public final fun getMessageItem ()Lio/getstream/chat/android/ui/common/state/messages/list/MessageItemState; + public final fun getModifier ()Landroidx/compose/ui/Modifier; public fun hashCode ()I public fun toString ()Ljava/lang/String; } diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt index 7e5ce091379..cdc5e73d9a7 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/channels/list/ChannelItem.kt @@ -313,7 +313,7 @@ internal fun RowScope.DefaultChannelItemTrailingContent( Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { if (isLastMessageFromCurrentUser) { ChatTheme.componentFactory.ChannelItemReadStatusIndicator( diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt index 204ce0153c8..78ccad5cc69 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/channels/MessageReadStatusIcon.kt @@ -167,7 +167,7 @@ private fun IsSentIcon(modifier: Modifier) { @Composable private fun IsDeliveredIcon(modifier: Modifier) { Icon( - modifier = Modifier.testTag("Stream_MessageReadStatus_isDelivered"), + modifier = modifier.testTag("Stream_MessageReadStatus_isDelivered"), painter = painterResource(id = R.drawable.stream_compose_message_seen), contentDescription = stringResource( R.string.stream_ui_message_list_semantics_message_status_delivered, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt index b15ec63f902..3cd85fec8fc 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt @@ -105,6 +105,7 @@ public fun MessageFooter( } else { ChatTheme.componentFactory.MessageFooterStatusIndicator( params = MessageFooterStatusIndicatorParams( + modifier = Modifier.padding(end = 4.dp), messageItem = messageItem, ), ) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt index 5010e6397d6..f43022ab311 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactory.kt @@ -1406,7 +1406,7 @@ public interface ChatComponentFactory { ) { if (params.messageItem.isMessageDelivered) { MessageReadStatusIcon( - modifier = Modifier.padding(end = 4.dp), + modifier = params.modifier, message = params.messageItem.message, isMessageRead = params.messageItem.isMessageRead, isMessageDelivered = params.messageItem.isMessageDelivered, @@ -1414,7 +1414,7 @@ public interface ChatComponentFactory { ) } else { MessageFooterStatusIndicator( - modifier = Modifier.padding(end = 4.dp), + modifier = params.modifier, message = params.messageItem.message, isMessageRead = params.messageItem.isMessageRead, readCount = params.messageItem.messageReadBy.size, diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt index 0fe26d38eff..d63cdc1fc9c 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/theme/ChatComponentFactoryParams.kt @@ -65,7 +65,9 @@ public data class ChannelMediaAttachmentsPreviewBottomBarParams( * Parameters for the [ChatComponentFactory.MessageFooterStatusIndicator] component. * * @param messageItem The message item state. + * @param modifier Modifier for styling. */ public data class MessageFooterStatusIndicatorParams( val messageItem: MessageItemState, + val modifier: Modifier = Modifier, ) diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_delivered_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_delivered_status.png index a8209b96da202d22c0af76563cf4148f8f10181c..610bc498f6e9ff752951358a3256d90d47efa4a4 100644 GIT binary patch delta 21632 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 diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_pending_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_pending_status.png index 4af9d72f472264a20c6da4a7e20a5c336b696010..7800d70b03e45d47cabd6b5cf656b1d727f5a2f2 100644 GIT binary patch literal 25762 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 dD+|-%{Fazysm6ay5si{{}GX1El}} literal 25740 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 =`Kk2%r;=s2%_#IS^Fd4UEy@wIW?zPJTX2^`Qd+ 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 diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_seen_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_seen_status.png index 82390220060bc293bdd830e962359bd8eed1748c..3005fcdcd40498f8f1f11c3f33cb1348914d6123 100644 GIT binary patch literal 25600 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 literal 25587 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 M+%&vVu4y0oSG9M@UH||9 diff --git a/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_sent_status.png b/stream-chat-android-compose/src/test/snapshots/images/io.getstream.chat.android.compose.ui.channels_ChannelItemTest_last_message_sent_status.png index 2451b4566cef732f61eebd1ebf5c8177ee152f20..46979a02fcf4ff368c443fcd281318bb5ee4d26a 100644 GIT binary patch delta 21189 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 delta 21187 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 z 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 From 0ef7cb32f2c61414f69124b55f4d6e2563efb0f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 13:59:13 +0000 Subject: [PATCH 35/58] Mark messages as delivered on push notification --- stream-chat-android-client/build.gradle.kts | 1 + .../client/notifications/ChatNotifications.kt | 28 +++- .../getstream/chat/android/client/Mother.kt | 17 ++ .../ChatNotificationsImplTest.kt | 158 ++++++++++++++++++ 4 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt diff --git a/stream-chat-android-client/build.gradle.kts b/stream-chat-android-client/build.gradle.kts index 796b243a9f3..7bc51f16c50 100644 --- a/stream-chat-android-client/build.gradle.kts +++ b/stream-chat-android-client/build.gradle.kts @@ -115,6 +115,7 @@ dependencies { testImplementation(libs.stream.result) testImplementation(libs.androidx.test.junit) testImplementation(libs.androidx.lifecycle.runtime.testing) + testImplementation(libs.androidx.work.testing) testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) testImplementation(libs.turbine) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt index dbc804c1987..8434df3f24f 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt @@ -28,6 +28,7 @@ import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.notifications.handler.NotificationHandler import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Device +import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PushMessage import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger @@ -38,7 +39,12 @@ internal interface ChatNotifications { fun onSetUser(user: User) fun setDevice(device: Device) suspend fun deleteDevice() - fun onPushMessage(message: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener) + fun onPushMessage( + pushMessage: PushMessage, + pushNotificationReceivedListener: PushNotificationReceivedListener = + PushNotificationReceivedListener { _, _ -> }, + ) + fun onChatEvent(event: ChatEvent) suspend fun onLogout() fun displayNotification(notification: ChatNotification) @@ -51,6 +57,7 @@ internal class ChatNotificationsImpl( private val notificationConfig: NotificationConfig, private val context: Context, private val scope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), + private val chatClient: ChatClient = ChatClient.instance(), ) : ChatNotifications { private val logger by taggedLogger("Chat:Notifications") @@ -95,15 +102,21 @@ internal class ChatNotificationsImpl( } override fun onPushMessage( - message: PushMessage, + pushMessage: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener, ) { - logger.i { "[onReceivePushMessage] message: $message" } + logger.i { "[onPushMessage] message: $pushMessage" } + + pushNotificationReceivedListener.onPushNotificationReceived(pushMessage.channelType, pushMessage.channelId) - pushNotificationReceivedListener.onPushNotificationReceived(message.channelType, message.channelId) + val message = Message( + id = pushMessage.messageId, + cid = "${pushMessage.channelType}:${pushMessage.channelId}", + ) + chatClient.messageReceiptManager.markMessagesAsDelivered(messages = listOf(message)) - if (notificationConfig.shouldShowNotificationOnPush() && !handler.onPushMessage(message)) { - handlePushMessage(message) + if (notificationConfig.shouldShowNotificationOnPush() && !handler.onPushMessage(pushMessage)) { + handlePushMessage(pushMessage) } } @@ -193,6 +206,7 @@ internal class ChatNotificationsImpl( handler.showNotification(notification) } } + else -> handler.showNotification(notification) } } @@ -210,7 +224,7 @@ internal object NoOpChatNotifications : ChatNotifications { override fun setDevice(device: Device) = Unit override suspend fun deleteDevice() = Unit override fun onPushMessage( - message: PushMessage, + pushMessage: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener, ) = Unit diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index 9ba2a6680d1..d437f0f627a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -91,6 +91,7 @@ import io.getstream.chat.android.models.ConnectionState import io.getstream.chat.android.models.FilterObject import io.getstream.chat.android.models.Filters import io.getstream.chat.android.models.InitializationState +import io.getstream.chat.android.models.PushMessage import io.getstream.chat.android.models.User import io.getstream.chat.android.models.querysort.QuerySortByField import io.getstream.chat.android.models.querysort.QuerySorter @@ -1279,6 +1280,22 @@ internal object Mother { ) } +internal fun randomPushMessage( + messageId: String = randomString(), + channelId: String = randomString(), + channelType: String = randomString(), + getstream: Map = emptyMap(), + extraData: Map = emptyMap(), + metadata: Map = emptyMap(), +) = PushMessage( + messageId = messageId, + channelId = channelId, + channelType = channelType, + getstream = getstream, + extraData = extraData, + metadata = metadata, +) + internal fun randomMessageReceipt( messageId: String = randomString(), type: String = randomString(), diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt new file mode 100644 index 00000000000..aae3b627afc --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2014-2025 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.notifications + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.testing.WorkManagerTestInitHelper +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.notifications.handler.NotificationConfig +import io.getstream.chat.android.client.notifications.handler.NotificationHandler +import io.getstream.chat.android.client.randomPushMessage +import io.getstream.chat.android.client.receipts.MessageReceiptManager +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.PushMessage +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [33]) +internal class ChatNotificationsImplTest { + + @Test + fun `onPushMessage calls onPushNotificationReceived`() { + val pushMessage = randomPushMessage() + val mockListener = mock() + val fixture = Fixture() + val sut = fixture.get() + + sut.onPushMessage(pushMessage, mockListener) + + verify(mockListener).onPushNotificationReceived( + channelType = pushMessage.channelType, + channelId = pushMessage.channelId, + ) + } + + @Test + fun `onPushMessage marks message as delivered`() { + val pushMessage = randomPushMessage() + val fixture = Fixture() + val sut = fixture.get() + + sut.onPushMessage(pushMessage) + + val messages = listOf( + Message( + id = pushMessage.messageId, + cid = "${pushMessage.channelType}:${pushMessage.channelId}", + ), + ) + fixture.verifyMarkMessagesAsDeliveredCalled(messages) + } + + @Test + fun `onPushMessage schedules work when shouldShowNotificationOnPush is true and handler does not handle message`() { + val pushMessage = randomPushMessage() + val fixture = Fixture() + val sut = fixture.get() + + sut.onPushMessage(pushMessage) + + val workInfos = getLoadNotificationDataWorkerInfos() + assertNotNull(workInfos.firstOrNull()) + } + + @Test + fun `onPushMessage does not schedule work when shouldShowNotificationOnPush is false`() { + val pushMessage = randomPushMessage() + val fixture = Fixture() + .givenNotificationConfig(config = NotificationConfig(shouldShowNotificationOnPush = { false })) + val sut = fixture.get() + + sut.onPushMessage(pushMessage) + + val workInfos = getLoadNotificationDataWorkerInfos() + assertNull(workInfos.firstOrNull()) + } + + @Test + fun `onPushMessage does not schedule work when handler handles message`() { + val pushMessage = randomPushMessage() + val fixture = Fixture() + .givenOnPushMessageHandled(pushMessage = pushMessage, handled = true) + val sut = fixture.get() + + sut.onPushMessage(pushMessage) + + val workInfos = getLoadNotificationDataWorkerInfos() + assertNull(workInfos.firstOrNull()) + } + + private class Fixture { + + private var mockNotificationHandler = mock() + private var notificationConfig = NotificationConfig() + private val mockMessageReceiptManager = mock() + + private val mockChatClient = mock { + on { messageReceiptManager } doReturn mockMessageReceiptManager + } + + fun givenOnPushMessageHandled(pushMessage: PushMessage, handled: Boolean) = apply { + whenever(mockNotificationHandler.onPushMessage(pushMessage)) doReturn handled + } + + fun givenNotificationConfig(config: NotificationConfig) = apply { + notificationConfig = config + } + + fun verifyMarkMessagesAsDeliveredCalled(messages: List) { + verify(mockMessageReceiptManager).markMessagesAsDelivered(messages) + } + + fun get(): ChatNotificationsImpl { + val context = ApplicationProvider.getApplicationContext() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + + return ChatNotificationsImpl( + handler = mockNotificationHandler, + notificationConfig = notificationConfig, + context = context, + scope = mock(), + chatClient = mockChatClient, + ) + } + } +} + +private fun getLoadNotificationDataWorkerInfos(): List { + val workInfos = WorkManager + .getInstance(ApplicationProvider.getApplicationContext()) + .getWorkInfosByTag(LoadNotificationDataWorker::class.qualifiedName!!).get() + return workInfos +} From 67dd697e9eb6a31a1de8a725d1a100d36265cdca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 14:03:09 +0000 Subject: [PATCH 36/58] sonar lint --- .../repository/domain/user/internal/PrivacySettingsMapper.kt | 2 +- .../state/plugin/state/channel/internal/ChannelMutableState.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt index 14ea36865af..7f2ec9853eb 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/user/internal/PrivacySettingsMapper.kt @@ -33,7 +33,7 @@ internal fun PrivacySettings.toEntity(): PrivacySettingsEntity { enabled = it.enabled, ) }, - deliveryReceipts = deliveryReceipts?.let { it -> + deliveryReceipts = deliveryReceipts?.let { DeliveryReceiptsEntity( enabled = it.enabled, ) diff --git a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt index db0b1acd7e6..b7e5b4d5ec6 100644 --- a/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt +++ b/stream-chat-android-state/src/main/java/io/getstream/chat/android/state/plugin/state/channel/internal/ChannelMutableState.kt @@ -35,7 +35,6 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessagesState import io.getstream.chat.android.models.TypingEvent import io.getstream.chat.android.models.User -import io.getstream.chat.android.state.plugin.state.channel.internal.ChannelMutableState.Companion.LIMIT_MULTIPLIER import io.getstream.chat.android.state.utils.internal.combineStates import io.getstream.chat.android.state.utils.internal.mapState import io.getstream.log.taggedLogger From 07317a29d05e02898b66184b37824ad27b2d4ec5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 14:11:28 +0000 Subject: [PATCH 37/58] Mark channel as delivered on query a single channel --- .../client/plugin/MessageDeliveredPlugin.kt | 15 ++++++ .../plugin/MessageDeliveredPluginTest.kt | 50 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt index 3e1f3e37f97..52862486c45 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt @@ -17,6 +17,7 @@ package io.getstream.chat.android.client.plugin import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.api.models.QueryChannelRequest import io.getstream.chat.android.client.api.models.QueryChannelsRequest import io.getstream.chat.android.client.plugin.factory.PluginFactory import io.getstream.chat.android.client.receipts.MessageReceiptManager @@ -31,11 +32,25 @@ internal class MessageDeliveredPlugin( chatClient: ChatClient = ChatClient.instance(), private val messageReceiptManager: MessageReceiptManager = chatClient.messageReceiptManager, ) : Plugin { + override suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { result.onSuccess { channels -> messageReceiptManager.markChannelsAsDelivered(channels) } } + + override suspend fun onQueryChannelResult( + result: Result, + channelType: String, + channelId: String, + request: QueryChannelRequest, + ) { + result.onSuccess { channel -> + if (request.pagination() == null) { // only mark as delivered on initial load + messageReceiptManager.markChannelsAsDelivered(channels = listOf(channel)) + } + } + } } internal object MessageDeliveredPluginFactory : PluginFactory { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt index 5e099e0c048..3b86b3be71f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt @@ -23,6 +23,7 @@ import io.getstream.result.Result import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times @@ -52,6 +53,55 @@ internal class MessageDeliveredPluginTest { fixture.verifyMarkChannelsAsDeliveredCalled(never()) } + @Test + fun `on query channel with successful result and null pagination, should mark channel as delivered`() = runTest { + val channel = randomChannel() + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelResult( + result = Result.Success(channel), + channelType = channel.type, + channelId = channel.id, + request = mock { on { pagination() } doReturn null }, + ) + + fixture.verifyMarkChannelsAsDeliveredCalled(channels = listOf(channel)) + } + + @Test + fun `on query channel with successful result and non-null pagination, should not mark channel as delivered`() = + runTest { + val channel = randomChannel() + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelResult( + result = Result.Success(channel), + channelType = channel.type, + channelId = channel.id, + request = mock { on { pagination() } doReturn mock() }, + ) + + fixture.verifyMarkChannelsAsDeliveredCalled(never()) + } + + @Test + fun `on query channel with failure result, should not mark channel as delivered`() = runTest { + val channel = randomChannel() + val fixture = Fixture() + val sut = fixture.get() + + sut.onQueryChannelResult( + result = Result.Failure(mock()), + channelType = channel.type, + channelId = channel.id, + request = mock(), + ) + + fixture.verifyMarkChannelsAsDeliveredCalled(never()) + } + private class Fixture { private val mockMessageReceiptManager = mock() From 98186c36b158dbda92bafb18eb3f04c452f3ca0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 14:39:24 +0000 Subject: [PATCH 38/58] Provide a ChatClient lazily in ChatNotifications --- .../chat/android/client/notifications/ChatNotifications.kt | 3 ++- .../android/client/notifications/ChatNotificationsImplTest.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt index 8434df3f24f..8f1cbe42052 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt @@ -57,10 +57,11 @@ internal class ChatNotificationsImpl( private val notificationConfig: NotificationConfig, private val context: Context, private val scope: CoroutineScope = CoroutineScope(DispatcherProvider.IO), - private val chatClient: ChatClient = ChatClient.instance(), + private val chatClientProvider: () -> ChatClient = { ChatClient.instance() }, ) : ChatNotifications { private val logger by taggedLogger("Chat:Notifications") + private val chatClient: ChatClient by lazy { chatClientProvider() } private val pushTokenUpdateHandler = PushTokenUpdateHandler() private val showedMessages = mutableSetOf() private val permissionManager: NotificationPermissionManager = diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt index aae3b627afc..5f6c1ef76b7 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt @@ -144,7 +144,7 @@ internal class ChatNotificationsImplTest { notificationConfig = notificationConfig, context = context, scope = mock(), - chatClient = mockChatClient, + chatClientProvider = { mockChatClient }, ) } } From c8b8866f235ba6e12c1a27faf77bb35aca72b632 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 15:13:31 +0000 Subject: [PATCH 39/58] Hide the message status indicator when a message is deleted --- .../compose/ui/components/messages/MessageFooter.kt | 3 ++- .../chat/android/ui/common/utils/extensions/Message.kt | 7 +++++++ .../viewholder/decorator/internal/FootnoteDecorator.kt | 10 ++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt index 3cd85fec8fc..e9f068a1e6f 100644 --- a/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt +++ b/stream-chat-android-compose/src/main/java/io/getstream/chat/android/compose/ui/components/messages/MessageFooter.kt @@ -44,6 +44,7 @@ import io.getstream.chat.android.compose.ui.util.clickable import io.getstream.chat.android.core.utils.date.truncateFuture import io.getstream.chat.android.models.Message import io.getstream.chat.android.ui.common.state.messages.list.MessageItemState +import io.getstream.chat.android.ui.common.utils.extensions.shouldShowMessageStatusIndicator /** * Default message footer, which contains either [MessageThreadFooter] or the default footer, which @@ -102,7 +103,7 @@ public fun MessageFooter( maxLines = 1, color = ChatTheme.colors.textLowEmphasis, ) - } else { + } else if (message.shouldShowMessageStatusIndicator()) { ChatTheme.componentFactory.MessageFooterStatusIndicator( params = MessageFooterStatusIndicatorParams( modifier = Modifier.padding(end = 4.dp), diff --git a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Message.kt b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Message.kt index a6e315e88d2..2d569ff1b8b 100644 --- a/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Message.kt +++ b/stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/utils/extensions/Message.kt @@ -17,10 +17,13 @@ package io.getstream.chat.android.ui.common.utils.extensions import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.utils.message.isDeleted +import io.getstream.chat.android.client.utils.message.isEphemeral import io.getstream.chat.android.core.internal.InternalStreamChatApi import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageModerationAction import io.getstream.chat.android.models.ModerationAction +import io.getstream.chat.android.models.SyncStatus import io.getstream.chat.android.models.User /** @@ -32,6 +35,10 @@ public fun Message.isMine(chatClient: ChatClient): Boolean = chatClient.clientSt @InternalStreamChatApi public fun Message.isMine(currentUser: User?): Boolean = currentUser?.id == user.id +@InternalStreamChatApi +public fun Message.shouldShowMessageStatusIndicator(): Boolean = + !isEphemeral() && !isDeleted() && syncStatus != SyncStatus.FAILED_PERMANENTLY + /** * @return if the message failed at moderation or not. */ diff --git a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt index f1c3a676809..ff44dbe8b92 100644 --- a/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt +++ b/stream-chat-android-ui-components/src/main/kotlin/io/getstream/chat/android/ui/feature/messages/list/adapter/viewholder/decorator/internal/FootnoteDecorator.kt @@ -31,6 +31,7 @@ import io.getstream.chat.android.ui.ChatUI import io.getstream.chat.android.ui.R import io.getstream.chat.android.ui.common.helper.DateFormatter import io.getstream.chat.android.ui.common.state.messages.list.DeletedMessageVisibility +import io.getstream.chat.android.ui.common.utils.extensions.shouldShowMessageStatusIndicator import io.getstream.chat.android.ui.feature.messages.list.MessageListItemStyle import io.getstream.chat.android.ui.feature.messages.list.MessageListViewStyle import io.getstream.chat.android.ui.feature.messages.list.adapter.MessageListItem @@ -389,11 +390,13 @@ internal class FootnoteDecorator( SyncStatus.SYNC_NEEDED, SyncStatus.AWAITING_ATTACHMENTS, -> itemStyle.iconIndicatorPendingSync + SyncStatus.COMPLETED -> when { data.isMessageRead -> itemStyle.iconIndicatorRead data.isMessageDelivered -> itemStyle.iconIndicatorDelivered else -> itemStyle.iconIndicatorSent } + else -> null } if (statusIndicator != null) { @@ -422,13 +425,8 @@ internal class FootnoteDecorator( } private fun shouldHideReadRelatedInfo(data: MessageListItem.MessageItem): Boolean { - val status = data.message.syncStatus val isNotBottomPosition = data.isNotBottomPosition() val isTheirs = data.isTheirs - val isEphemeral = data.message.isEphemeral() - val isDeleted = data.message.isDeleted() - val isFailedPermanently = status == SyncStatus.FAILED_PERMANENTLY - - return isNotBottomPosition || isTheirs || isEphemeral || isDeleted || isFailedPermanently + return isNotBottomPosition || isTheirs || !data.message.shouldShowMessageStatusIndicator() } } From 662bd05e5cfb8f2f923a003abc655a3e50f249b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Thu, 30 Oct 2025 16:29:57 +0000 Subject: [PATCH 40/58] Do not expose ChatClient.markMessagesAsDelivered --- .../api/stream-chat-android-client.api | 1 - .../chat/android/client/ChatClient.kt | 15 ---------- .../client/ChatClientChannelApiTests.kt | 28 ------------------- 3 files changed, 44 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 f492300ddf3..308a4d93123 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -114,7 +114,6 @@ public final class io/getstream/chat/android/client/ChatClient { public static synthetic fun keystroke$default (Lio/getstream/chat/android/client/ChatClient;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun markAllRead ()Lio/getstream/result/call/Call; public final fun markMessageRead (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; - public final fun markMessagesAsDelivered (Ljava/util/List;)Lio/getstream/result/call/Call; public final fun markRead (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markThreadRead (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun markThreadUnread (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; 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 c376c2a8d90..441c0a9d078 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 @@ -2911,21 +2911,6 @@ internal constructor( } } - /** - * Marks the given messages as delivered. - * - * @param messages The list of messages to mark as delivered. - */ - @CheckResult - public fun markMessagesAsDelivered(messages: List): Call = - api.markDelivered(messages) - .doOnStart(userScope) { - logger.d { "[markMessagesAsDelivered] #doOnStart; messages: ${messages.size}" } - } - .doOnResult(userScope) { result -> - logger.v { "[markMessagesAsDelivered] #doOnResult; completed: $result" } - } - /** * Marks a given thread as read. * diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt index 1969636b507..121833c82c8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientChannelApiTests.kt @@ -44,7 +44,6 @@ import io.getstream.chat.android.randomExtraData import io.getstream.chat.android.randomInt import io.getstream.chat.android.randomMember import io.getstream.chat.android.randomMessage -import io.getstream.chat.android.randomMessageList import io.getstream.chat.android.randomString import io.getstream.chat.android.randomUser import io.getstream.result.Error @@ -988,33 +987,6 @@ internal class ChatClientChannelApiTests : BaseChatClientTest() { verifyNetworkError(result, errorCode) } - @Test - fun markMessagesAsDeliveredSuccess() = runTest { - // given - val messages = randomMessageList(10) - val sut = Fixture() - .givenMarkDeliveredResult(RetroSuccess(Unit).toRetrofitCall()) - .get() - // when - val result = sut.markMessagesAsDelivered(messages).await() - // then - verifySuccess(result, Unit) - } - - @Test - fun markMessagesAsDeliveredError() = runTest { - // given - val messages = randomMessageList(10) - val errorCode = positiveRandomInt() - val sut = Fixture() - .givenMarkDeliveredResult(RetroError(errorCode).toRetrofitCall()) - .get() - // when - val result = sut.markMessagesAsDelivered(messages).await() - // then - verifyNetworkError(result, errorCode) - } - @Test fun markReadSuccess() = runTest { // given From 0ec4d07f5bd1cdd055e87ba06400410ecea5cbfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Oct 2025 08:54:52 +0000 Subject: [PATCH 41/58] feat: Add delivery_events flag to Config Added a `deliveryEventsEnabled` flag to the `Config` model. This allows disabling message delivery events on a per-channel basis. Delivery receipts will not be stored or sent if this flag is disabled in the channel configuration. --- .../client/api2/model/dto/ConfigDto.kt | 1 + .../client/receipts/MessageReceiptManager.kt | 4 ++++ .../android/client/EventChatJsonProvider.kt | 1 + .../getstream/chat/android/client/Mother.kt | 2 ++ .../parser2/testdata/ChannelDtoTestData.kt | 2 ++ .../receipts/MessageReceiptManagerTest.kt | 12 +++++++++++ .../api/stream-chat-android-core.api | 20 ++++++++++--------- .../getstream/chat/android/models/Config.kt | 5 +++++ .../io/getstream/chat/android/Mother.kt | 2 ++ 9 files changed, 40 insertions(+), 9 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt index 4f2e3f956de..7d728fa7559 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt @@ -26,6 +26,7 @@ internal data class ConfigDto( val name: String?, val typing_events: Boolean, val read_events: Boolean, + val delivery_events: Boolean?, val connect_events: Boolean, val search: Boolean, val reactions: Boolean, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index f02e8e3939c..657f7342c70 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -112,6 +112,10 @@ internal class MessageReceiptManager( } private fun getUndeliveredMessage(channel: Channel): Message? { + if (!channel.config.deliveryEventsEnabled) { + logger.w { "[getUndeliveredMessage] Delivery events disabled for channel ${channel.cid}" } + return null + } val currentUser = getCurrentUser() ?: run { logger.w { "[getUndeliveredMessage] Cannot get undelivered message: current user is null" } return null diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt index 11c2ccf1f89..368cb2b149d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/EventChatJsonProvider.kt @@ -691,6 +691,7 @@ private fun createConfigJsonString() = "name": "team", "typing_events": true, "read_events": true, + "delivery_events": true, "connect_events": true, "search": true, "reactions": true, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt index d437f0f627a..72b9d1dba3d 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/Mother.kt @@ -481,6 +481,7 @@ internal object Mother { name: String? = randomString(), typing_events: Boolean = randomBoolean(), read_events: Boolean = randomBoolean(), + delivery_events: Boolean = randomBoolean(), connect_events: Boolean = randomBoolean(), search: Boolean = randomBoolean(), reactions: Boolean = randomBoolean(), @@ -507,6 +508,7 @@ internal object Mother { name = name, typing_events = typing_events, read_events = read_events, + delivery_events = delivery_events, connect_events = connect_events, search = search, reactions = reactions, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt index e3a9ead14c0..4d20e0b168a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/parser2/testdata/ChannelDtoTestData.kt @@ -38,6 +38,7 @@ internal object ChannelDtoTestData { "name" : "config1", "typing_events": true, "read_events": true, + "delivery_events": true, "connect_events": true, "search": false, "reactions": true, @@ -74,6 +75,7 @@ internal object ChannelDtoTestData { name = "config1", typing_events = true, read_events = true, + delivery_events = true, connect_events = true, search = false, reactions = true, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index c44d98127dd..ae774d27a3e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomChannelUserRead +import io.getstream.chat.android.randomConfig import io.getstream.chat.android.randomDate import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomMessageList @@ -197,6 +198,17 @@ internal class MessageReceiptManagerTest { fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) } + @Test + fun `should skip storing channel delivery receipts when delivery events are disabled`() = runTest { + val channel = randomChannel(config = randomConfig(deliveryEventsEnabled = false)) + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + @Test fun `should skip storing channel delivery receipts when current user is null`() = runTest { val channel = randomChannel() diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 6cbec0cc98f..8e4877523b2 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -686,8 +686,8 @@ public final class io/getstream/chat/android/models/Command { public final class io/getstream/chat/android/models/Config { public fun ()V - public fun (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZ)V - public synthetic fun (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZ)V + public synthetic fun (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Ljava/util/Date; public final fun component10 ()Z public final fun component11 ()Z @@ -696,16 +696,17 @@ public final class io/getstream/chat/android/models/Config { public final fun component14 ()Z public final fun component15 ()Z public final fun component16 ()Z - public final fun component17 ()Ljava/lang/String; - public final fun component18 ()I - public final fun component19 ()Ljava/lang/String; + public final fun component17 ()Z + public final fun component18 ()Ljava/lang/String; + public final fun component19 ()I public final fun component2 ()Ljava/util/Date; public final fun component20 ()Ljava/lang/String; public final fun component21 ()Ljava/lang/String; - public final fun component22 ()Ljava/util/List; - public final fun component23 ()Z + public final fun component22 ()Ljava/lang/String; + public final fun component23 ()Ljava/util/List; public final fun component24 ()Z public final fun component25 ()Z + public final fun component26 ()Z public final fun component3 ()Ljava/lang/String; public final fun component4 ()Z public final fun component5 ()Z @@ -713,8 +714,8 @@ public final class io/getstream/chat/android/models/Config { public final fun component7 ()Z public final fun component8 ()Z public final fun component9 ()Z - public final fun copy (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZ)Lio/getstream/chat/android/models/Config; - public static synthetic fun copy$default (Lio/getstream/chat/android/models/Config;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZILjava/lang/Object;)Lio/getstream/chat/android/models/Config; + public final fun copy (Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZ)Lio/getstream/chat/android/models/Config; + public static synthetic fun copy$default (Lio/getstream/chat/android/models/Config;Ljava/util/Date;Ljava/util/Date;Ljava/lang/String;ZZZZZZZZZZZZZZLjava/lang/String;ILjava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;ZZZILjava/lang/Object;)Lio/getstream/chat/android/models/Config; public fun equals (Ljava/lang/Object;)Z public final fun getAutomod ()Ljava/lang/String; public final fun getAutomodBehavior ()Ljava/lang/String; @@ -723,6 +724,7 @@ public final class io/getstream/chat/android/models/Config { public final fun getConnectEventsEnabled ()Z public final fun getCreatedAt ()Ljava/util/Date; public final fun getCustomEventsEnabled ()Z + public final fun getDeliveryEventsEnabled ()Z public final fun getMarkMessagesPending ()Z public final fun getMaxMessageLength ()I public final fun getMessageRemindersEnabled ()Z diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt index a58fa5609e4..1db1db59a4d 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/Config.kt @@ -50,6 +50,11 @@ public data class Config( */ val readEventsEnabled: Boolean = true, + /** + * Determines if events are fired for message deliveries. Enabled by default. + */ + val deliveryEventsEnabled: Boolean = true, + /** * Determines if events are fired for connecting and disconnecting to a chat. Enabled by default. */ diff --git a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt index ad9f5e5b9b4..ebf4da7cd8b 100644 --- a/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt +++ b/stream-chat-android-core/src/testFixtures/kotlin/io/getstream/chat/android/Mother.kt @@ -526,6 +526,7 @@ public fun randomConfig( name: String = randomString(), typingEventsEnabled: Boolean = randomBoolean(), readEventsEnabled: Boolean = randomBoolean(), + deliveryEventsEnabled: Boolean = randomBoolean(), connectEventsEnabled: Boolean = randomBoolean(), searchEnabled: Boolean = randomBoolean(), isReactionsEnabled: Boolean = randomBoolean(), @@ -548,6 +549,7 @@ public fun randomConfig( name = name, typingEventsEnabled = typingEventsEnabled, readEventsEnabled = readEventsEnabled, + deliveryEventsEnabled = deliveryEventsEnabled, connectEventsEnabled = connectEventsEnabled, searchEnabled = searchEnabled, isReactionsEnabled = isReactionsEnabled, From 189b4c0c5b0100f7a65651f1003f4918f5c641e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Oct 2025 11:46:42 +0000 Subject: [PATCH 42/58] Refactor: Improve message delivery receipts logic This commit refactors the `MessageReceiptManager` to enhance the logic for marking messages as delivered. Key changes include: - Renaming `markMessagesAsDelivered(messages: List)` to `markMessageAsDelivered(message: Message)` and making it the public API. The old function is now private and handles batch processing. - Before marking a message as delivered, the manager now fetches the corresponding channel from the local repository or the API to ensure all conditions are met. - The responsibility of filtering messages that can be marked as delivered is moved into a new private function `canMarkMessageAsDelivered`. - Test cases have been updated to reflect these changes and improve coverage. --- .../chat/android/client/ChatClient.kt | 4 +- .../client/notifications/ChatNotifications.kt | 2 +- .../client/receipts/MessageReceiptManager.kt | 151 +++++++---- .../ChatNotificationsImplTest.kt | 14 +- .../receipts/MessageReceiptManagerTest.kt | 251 ++++++++++-------- 5 files changed, 242 insertions(+), 180 deletions(-) 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 441c0a9d078..8874de8f81a 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 @@ -338,7 +338,9 @@ internal constructor( scope = userScope, now = now, getCurrentUser = ::getCurrentUser, + channelRepository = repositoryFacade, messageReceiptRepository = repository, + api = api, ) private var pushNotificationReceivedListener: PushNotificationReceivedListener = @@ -463,7 +465,7 @@ internal constructor( is NewMessageEvent -> { notifications.onChatEvent(event) - messageReceiptManager.markMessagesAsDelivered(messages = listOf(event.message)) + messageReceiptManager.markMessageAsDelivered(event.message) } is NotificationReminderDueEvent -> notifications.onChatEvent(event) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt index 8f1cbe42052..6eed0784813 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt @@ -114,7 +114,7 @@ internal class ChatNotificationsImpl( id = pushMessage.messageId, cid = "${pushMessage.channelType}:${pushMessage.channelId}", ) - chatClient.messageReceiptManager.markMessagesAsDelivered(messages = listOf(message)) + chatClient.messageReceiptManager.markMessageAsDelivered(message) if (notificationConfig.shouldShowNotificationOnPush() && !handler.onPushMessage(pushMessage)) { handlePushMessage(pushMessage) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index 657f7342c70..5023d7ef9ef 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -16,17 +16,20 @@ package io.getstream.chat.android.client.receipts +import io.getstream.chat.android.client.api.ChatApi +import io.getstream.chat.android.client.api.models.QueryChannelRequest +import io.getstream.chat.android.client.extensions.cidToTypeAndId import io.getstream.chat.android.client.extensions.getCreatedAtOrThrow import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.extensions.internal.lastMessage import io.getstream.chat.android.client.extensions.userRead +import io.getstream.chat.android.client.persistance.repository.ChannelRepository import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.User -import io.getstream.chat.android.models.UserId import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -40,111 +43,143 @@ internal class MessageReceiptManager( private val scope: CoroutineScope, private val now: () -> Date, private val getCurrentUser: () -> User?, + private val channelRepository: ChannelRepository, private val messageReceiptRepository: MessageReceiptRepository, + private val api: ChatApi, ) { private val logger by taggedLogger("Chat:MessageReceiptManager") /** - * Request to mark the last undelivered messages in the given channels as delivered. + * Request to mark the given channels as delivered + * if delivery receipts are enabled for the current user. * - * A delivery message candidate is the last non-deleted message in the channel that: + * A channel can have a message marked as delivered only if: + * - Delivery events are enabled in the channel config * + * A delivery message candidate is the last non-deleted message in the channel that: + * - It was not sent by the current user + * - It is not a system message + * - It is not deleted * - Is not yet marked as read by the current user * - Is not yet marked as delivered by the current user */ fun markChannelsAsDelivered(channels: List) { - val deliveredMessageCandidates = channels.mapNotNull(::getUndeliveredMessage) + val currentUser = getCurrentUser() ?: run { + logger.w { "[markChannelsAsDelivered] Current user is null" } + return + } + + if (!currentUser.isDeliveryReceiptsEnabled) return + + val deliveredMessageCandidates = channels.mapNotNull { channel -> + channel.lastMessage?.takeIf { lastNonDeletedMessage -> + canMarkMessageAsDelivered(currentUser, channel, lastNonDeletedMessage) + } + } markMessagesAsDelivered(messages = deliveredMessageCandidates) } /** - * Request to mark the given messages as delivered if delivery receipts are enabled - * in the current user privacy settings. - * - * A message can be marked as delivered only if: + * Request to mark the given message as delivered + * if delivery receipts are enabled for the current user. * - * - It was not sent by the current user - * - It is not a system message - * - It is not deleted + * @see [markChannelsAsDelivered] for the conditions to mark a message as delivered. */ - fun markMessagesAsDelivered(messages: List) { - if (messages.isEmpty()) { - logger.w { "[markMessagesAsDelivered] No receipts to send" } + fun markMessageAsDelivered(message: Message) { + val currentUser = getCurrentUser() ?: run { + logger.w { "[markMessageAsDelivered] Current user is null" } return } - logger.d { "[markMessagesAsDelivered] Processing delivery receipts for ${messages.size} messages…" } + if (!currentUser.isDeliveryReceiptsEnabled) return - val currentUser = getCurrentUser() ?: run { - logger.w { "[markMessagesAsDelivered] Cannot send delivery receipts: current user is null" } - return - } + scope.launch { + val channel = getChannel(message.cid) ?: run { + logger.w { "[markMessageAsDelivered] Channel ${message.cid} not found" } + return@launch + } - // Check if delivery receipts are enabled for the current user - if (!currentUser.isDeliveryReceiptsEnabled) { - logger.w { "[markMessagesAsDelivered] Delivery receipts disabled for user ${currentUser.id}" } - return + if (canMarkMessageAsDelivered(currentUser, channel, message)) { + markMessagesAsDelivered(messages = listOf(message)) + } } + } - // Filter out messages that shouldn't have delivery receipts sent - val filteredMessages = messages.filter { message -> - shouldSendDeliveryReceipt(currentUserId = currentUser.id, message = message) - } - if (filteredMessages.size != messages.size) { - logger.d { - "[markMessagesAsDelivered] " + - "Skipping delivery receipts for ${messages.size - filteredMessages.size} messages" + private suspend fun getChannel(cid: String): Channel? = + channelRepository.selectChannel(cid) + ?: run { + val (channelType, channelId) = cid.cidToTypeAndId() + val request = QueryChannelRequest() + api.queryChannel(channelType, channelId, request) + .await().getOrNull() } - } - if (filteredMessages.isEmpty()) { + private fun markMessagesAsDelivered(messages: List) { + if (messages.isEmpty()) { logger.w { "[markMessagesAsDelivered] No receipts to send" } return } + logger.d { "[markMessagesAsDelivered] Processing delivery receipts for ${messages.size} messages…" } + scope.launch { - val receipts = filteredMessages.map { message -> message.toDeliveryReceipt() } + val receipts = messages.map { message -> message.toDeliveryReceipt() } messageReceiptRepository.upsertMessageReceipts(receipts) - logger.d { "[markMessagesAsDelivered] ${filteredMessages.size} delivery receipts upserted" } + logger.d { "[markMessagesAsDelivered] ${messages.size} delivery receipts upserted" } } } - private fun getUndeliveredMessage(channel: Channel): Message? { + private fun canMarkMessageAsDelivered( + currentUser: User, + channel: Channel, + message: Message, + ): Boolean { + // Check if delivery events are enabled for the channel if (!channel.config.deliveryEventsEnabled) { - logger.w { "[getUndeliveredMessage] Delivery events disabled for channel ${channel.cid}" } - return null - } - val currentUser = getCurrentUser() ?: run { - logger.w { "[getUndeliveredMessage] Cannot get undelivered message: current user is null" } - return null + logger.w { "[canMarkMessageAsDelivered] Delivery events disabled for channel ${channel.cid}" } + return false } - val userRead = channel.userRead(currentUser.id) ?: return null - // Get the last non-deleted message in the channel - val lastMessage = channel.lastMessage ?: return null - val createdAt = lastMessage.getCreatedAtOrThrow() - // Check if the last message is already marked as read - if (createdAt <= userRead.lastRead) return null - // Check if the last message is already marked as delivered - if (createdAt <= (userRead.lastDeliveredAt ?: NEVER)) return null - return lastMessage - } - - private fun shouldSendDeliveryReceipt(currentUserId: UserId, message: Message): Boolean { - // Don't send delivery receipts for messages sent by the current user - if (message.user.id == currentUserId) { + // Do not send delivery receipts for messages sent by the current user + if (message.user.id == currentUser.id) { + logger.w { + "[canMarkMessageAsDelivered] Message ${message.id} was sent by the current user ${currentUser.id}" + } return false } - // Don't send delivery receipts for system messages + // Do not send delivery receipts for system messages if (message.type == MessageType.SYSTEM) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is a system message" } return false } - // Don't send delivery receipts for deleted messages + // Do not send delivery receipts for deleted messages if (message.isDeleted()) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is deleted" } + return false + } + + val userRead = channel.userRead(currentUser.id) ?: run { + logger.w { + "[canMarkMessageAsDelivered] No read state for user ${currentUser.id} in channel ${channel.cid}" + } + return false + } + + val createdAt = message.getCreatedAtOrThrow() + + // Check if the last message is already marked as read + if (createdAt <= userRead.lastRead) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is already marked as read" } + return false + } + + // Check if the last message is already marked as delivered + if (createdAt <= (userRead.lastDeliveredAt ?: NEVER)) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is already marked as delivered" } return false } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt index 5f6c1ef76b7..2593d08b40f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt @@ -66,13 +66,11 @@ internal class ChatNotificationsImplTest { sut.onPushMessage(pushMessage) - val messages = listOf( - Message( - id = pushMessage.messageId, - cid = "${pushMessage.channelType}:${pushMessage.channelId}", - ), + val message = Message( + id = pushMessage.messageId, + cid = "${pushMessage.channelType}:${pushMessage.channelId}", ) - fixture.verifyMarkMessagesAsDeliveredCalled(messages) + fixture.verifyMarkMessageAsDeliveredCalled(message) } @Test @@ -131,8 +129,8 @@ internal class ChatNotificationsImplTest { notificationConfig = config } - fun verifyMarkMessagesAsDeliveredCalled(messages: List) { - verify(mockMessageReceiptManager).markMessagesAsDelivered(messages) + fun verifyMarkMessageAsDeliveredCalled(message: Message) { + verify(mockMessageReceiptManager).markMessageAsDelivered(message) } fun get(): ChatNotificationsImpl { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index ae774d27a3e..f092d093a09 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -18,181 +18,181 @@ package io.getstream.chat.android.client.receipts import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings +import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.extensions.internal.NEVER +import io.getstream.chat.android.client.persistance.repository.ChannelRepository import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository -import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.User import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomChannelUserRead import io.getstream.chat.android.randomConfig -import io.getstream.chat.android.randomDate import io.getstream.chat.android.randomMessage -import io.getstream.chat.android.randomMessageList import io.getstream.chat.android.randomUser +import io.getstream.chat.android.test.asCall +import io.getstream.result.Error import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verifyBlocking +import org.mockito.kotlin.wheneverBlocking import org.mockito.verification.VerificationMode import java.util.Date internal class MessageReceiptManagerTest { @Test - fun `store message delivery receipts success`() = runTest { - val deliveredMessage = randomMessage(deletedAt = null, deletedForMe = false) - val messages = listOf( - deliveredMessage, - randomMessage(user = CurrentUser), - randomMessage(type = "system"), - randomMessage(deletedAt = randomDate()), + fun `store message delivery receipt when channel is found from repository`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + val receipts = listOf( + MessageReceipt( + messageId = message.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = message.cid, + ), ) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) + } + + @Test + fun `store message delivery receipt when channel is found from API`() = runTest { + val message = DeliverableMessage val fixture = Fixture() + .givenChannelNotFromRepository() val sut = fixture.get() - sut.markMessagesAsDelivered(messages) + sut.markMessageAsDelivered(message) val receipts = listOf( MessageReceipt( - messageId = deliveredMessage.id, + messageId = message.id, type = MessageReceipt.TYPE_DELIVERY, createdAt = Now, - cid = deliveredMessage.cid, + cid = message.cid, ), ) fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) } @Test - fun `should skip storing message delivery receipts when current user is null`() = runTest { - val messages = randomMessageList(10) { randomMessage() } + fun `should skip storing message delivery receipt when channel is not found`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + .givenChannelNotFromRepository() + .givenChannelNotFoundFromApi() + val sut = fixture.get() + + sut.markMessageAsDelivered(message) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing message delivery receipt when current user is null`() = runTest { + val message = DeliverableMessage val fixture = Fixture().givenCurrentUser(user = null) val sut = fixture.get() - sut.markMessagesAsDelivered(messages) + sut.markMessageAsDelivered(message) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test - fun `should store message delivery receipts when current user privacy settings are undefined`() = runTest { - val currentUser = randomUser(privacySettings = null) - val messages = randomMessageList(10) { randomMessage(deletedAt = null, deletedForMe = false) } + fun `should store message delivery receipt when current user privacy settings are undefined`() = runTest { + val currentUser = CurrentUser.copy(privacySettings = null) + val message = DeliverableMessage val fixture = Fixture().givenCurrentUser(currentUser) val sut = fixture.get() - sut.markMessagesAsDelivered(messages) + sut.markMessageAsDelivered(message) - val receipts = messages.map { message -> + val receipts = listOf( MessageReceipt( messageId = message.id, type = MessageReceipt.TYPE_DELIVERY, createdAt = Now, cid = message.cid, - ) - } + ), + ) fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) } @Test - fun `should skip storing message delivery receipts when delivery receipts are disabled`() = runTest { - val currentUser = randomUser( + fun `should skip storing message delivery receipt when delivery receipts are disabled`() = runTest { + val currentUser = CurrentUser.copy( privacySettings = PrivacySettings( deliveryReceipts = DeliveryReceipts(enabled = false), ), ) - val messages = randomMessageList(10) { randomMessage() } + val message = DeliverableMessage val fixture = Fixture().givenCurrentUser(currentUser) val sut = fixture.get() - sut.markMessagesAsDelivered(messages) + sut.markMessageAsDelivered(message) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test - fun `should skip storing message delivery receipts with empty list`() = runTest { - val messages = emptyList() + fun `should skip storing message delivery receipt from the current user`() = runTest { + val message = DeliverableMessage.copy(user = CurrentUser) val fixture = Fixture() val sut = fixture.get() - sut.markMessagesAsDelivered(messages) + sut.markMessageAsDelivered(message) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test - fun `should skip storing message delivery receipts from the current user`() = runTest { - val messages = randomMessageList(10) { randomMessage(user = CurrentUser) } + fun `should skip storing message delivery receipt from system messages`() = runTest { + val message = DeliverableMessage.copy(type = "system") val fixture = Fixture() val sut = fixture.get() - sut.markMessagesAsDelivered(messages) + sut.markMessageAsDelivered(message) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test - fun `should skip storing message delivery receipts from system messages`() = runTest { - val messages = randomMessageList(10) { randomMessage(type = "system") } + fun `should skip storing message delivery receipt from deleted messages`() = runTest { + val message = DeliverableMessage.copy(deletedAt = Date()) val fixture = Fixture() val sut = fixture.get() - sut.markMessagesAsDelivered(messages) - - fixture.verifyUpsertMessageReceiptsCalled(never()) - } - - @Test - fun `should skip storing message delivery receipts from deleted messages`() = runTest { - val messages = randomMessageList(10) { randomMessage(deletedAt = Date()) } - val fixture = Fixture() - val sut = fixture.get() - - sut.markMessagesAsDelivered(messages) + sut.markMessageAsDelivered(message) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test fun `store channel delivery receipts success`() = runTest { - val deliveredMessage = randomMessage( - createdAt = Now, - deletedAt = null, - deletedForMe = false, - ) - val channel = randomChannel( - messages = listOf( - deliveredMessage, - randomMessage(user = CurrentUser, createdAt = NEVER), - randomMessage(type = "system", createdAt = NEVER), - randomMessage(deletedAt = randomDate(), createdAt = NEVER), - ), - read = listOf( - randomChannelUserRead( - user = CurrentUser, - lastRead = NEVER, - lastDeliveredAt = null, - ), - ), - ) - val channels = listOf(channel) + val message = DeliverableMessage + val channel = DeliverableChannel val fixture = Fixture() val sut = fixture.get() - sut.markChannelsAsDelivered(channels) + sut.markChannelsAsDelivered(channels = listOf(channel)) val receipts = listOf( MessageReceipt( - messageId = deliveredMessage.id, + messageId = message.id, type = MessageReceipt.TYPE_DELIVERY, createdAt = Now, - cid = deliveredMessage.cid, + cid = message.cid, ), ) fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) @@ -200,7 +200,7 @@ internal class MessageReceiptManagerTest { @Test fun `should skip storing channel delivery receipts when delivery events are disabled`() = runTest { - val channel = randomChannel(config = randomConfig(deliveryEventsEnabled = false)) + val channel = DeliverableChannel.copy(config = randomConfig(deliveryEventsEnabled = false)) val fixture = Fixture() val sut = fixture.get() @@ -211,7 +211,7 @@ internal class MessageReceiptManagerTest { @Test fun `should skip storing channel delivery receipts when current user is null`() = runTest { - val channel = randomChannel() + val channel = DeliverableChannel val fixture = Fixture().givenCurrentUser(user = null) val sut = fixture.get() @@ -222,59 +222,40 @@ internal class MessageReceiptManagerTest { @Test fun `should skip storing channel delivery receipts when user read is not found`() = runTest { - val channel = randomChannel( - messages = randomMessageList(10), - read = listOf(randomChannelUserRead()), - ) - val channels = listOf(channel) + val channel = DeliverableChannel.copy(read = listOf(randomChannelUserRead())) val fixture = Fixture() val sut = fixture.get() - sut.markChannelsAsDelivered(channels) + sut.markChannelsAsDelivered(channels = listOf(channel)) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test fun `should skip storing channel delivery receipts when last message is not found`() = runTest { - val channel = randomChannel( - messages = emptyList(), - read = listOf(randomChannelUserRead(user = CurrentUser)), - ) - val channels = listOf(channel) + val channel = DeliverableChannel.copy(messages = emptyList()) val fixture = Fixture() val sut = fixture.get() - sut.markChannelsAsDelivered(channels) + sut.markChannelsAsDelivered(channels = listOf(channel)) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test fun `should skip storing channel delivery receipts when last non-deleted message is not found`() = runTest { - val channel = randomChannel( - messages = randomMessageList(10) { randomMessage(deletedAt = Now) }, - read = listOf(randomChannelUserRead(user = CurrentUser)), - ) - val channels = listOf(channel) + val channel = DeliverableChannel.copy(messages = listOf(randomMessage(deletedAt = Now))) val fixture = Fixture() val sut = fixture.get() - sut.markChannelsAsDelivered(channels) + sut.markChannelsAsDelivered(channels = listOf(channel)) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test fun `should skip storing channel delivery receipts when last message is already read`() = runTest { - val channel = randomChannel( - messages = listOf( - randomMessage( - createdAt = Now, - deletedAt = null, - deletedForMe = false, - ), - ), + val channel = DeliverableChannel.copy( read = listOf( randomChannelUserRead( user = CurrentUser, @@ -283,25 +264,17 @@ internal class MessageReceiptManagerTest { ), ), ) - val channels = listOf(channel) val fixture = Fixture() val sut = fixture.get() - sut.markChannelsAsDelivered(channels) + sut.markChannelsAsDelivered(channels = listOf(channel)) fixture.verifyUpsertMessageReceiptsCalled(never()) } @Test fun `should skip storing channel delivery receipts when last message is already delivered`() = runTest { - val channel = randomChannel( - messages = listOf( - randomMessage( - createdAt = Now, - deletedAt = null, - deletedForMe = false, - ), - ), + val channel = DeliverableChannel.copy( read = listOf( randomChannelUserRead( user = CurrentUser, @@ -310,23 +283,58 @@ internal class MessageReceiptManagerTest { ), ), ) - val channels = listOf(channel) val fixture = Fixture() val sut = fixture.get() - sut.markChannelsAsDelivered(channels) + sut.markChannelsAsDelivered(channels = listOf(channel)) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + + @Test + fun `should skip storing channel delivery receipts with empty list`() = runTest { + val fixture = Fixture() + val sut = fixture.get() + + sut.markChannelsAsDelivered(channels = emptyList()) fixture.verifyUpsertMessageReceiptsCalled(never()) } private class Fixture { - private val mockMessageReceiptRepository = mock() private var getCurrentUser: () -> User? = { CurrentUser } + private val mockChannelRepository = mock { + onBlocking { selectChannel(DeliverableChannel.cid) } doReturn DeliverableChannel + } + private val mockMessageReceiptRepository = mock() + private val mockChatApi = mock { + on { + queryChannel( + channelType = any(), + channelId = any(), + query = any(), + ) + } doReturn DeliverableChannel.asCall() + } fun givenCurrentUser(user: User?) = apply { getCurrentUser = { user } } + fun givenChannelNotFromRepository() = apply { + wheneverBlocking { mockChannelRepository.selectChannel(cid = any()) } doReturn null + } + + fun givenChannelNotFoundFromApi() = apply { + wheneverBlocking { + mockChatApi.queryChannel( + channelType = any(), + channelId = any(), + query = any(), + ) + } doReturn mock().asCall() + } + fun verifyUpsertMessageReceiptsCalled( mode: VerificationMode = times(1), receipts: List? = null, @@ -340,7 +348,9 @@ internal class MessageReceiptManagerTest { scope = CoroutineScope(UnconfinedTestDispatcher()), now = { Now }, getCurrentUser = getCurrentUser, + channelRepository = mockChannelRepository, messageReceiptRepository = mockMessageReceiptRepository, + api = mockChatApi, ) } } @@ -352,3 +362,20 @@ private val CurrentUser = randomUser( deliveryReceipts = DeliveryReceipts(enabled = true), ), ) + +private val DeliverableMessage = randomMessage( + createdAt = Now, + deletedAt = null, + deletedForMe = false, +) + +private val DeliverableChannel = randomChannel( + messages = listOf(DeliverableMessage), + read = listOf( + randomChannelUserRead( + user = CurrentUser, + lastRead = NEVER, + lastDeliveredAt = null, + ), + ), +) From 8f401155b16af128bd7a35b922d6ee0a82c6931c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Oct 2025 11:47:17 +0000 Subject: [PATCH 43/58] Add DELIVERY_EVENTS to ChannelCapabilities --- stream-chat-android-core/api/stream-chat-android-core.api | 1 + .../io/getstream/chat/android/models/ChannelCapabilities.kt | 3 +++ 2 files changed, 4 insertions(+) diff --git a/stream-chat-android-core/api/stream-chat-android-core.api b/stream-chat-android-core/api/stream-chat-android-core.api index 8e4877523b2..7f9d23caac9 100644 --- a/stream-chat-android-core/api/stream-chat-android-core.api +++ b/stream-chat-android-core/api/stream-chat-android-core.api @@ -499,6 +499,7 @@ public final class io/getstream/chat/android/models/ChannelCapabilities { public static final field DELETE_ANY_MESSAGE Ljava/lang/String; public static final field DELETE_CHANNEL Ljava/lang/String; public static final field DELETE_OWN_MESSAGE Ljava/lang/String; + public static final field DELIVERY_EVENTS Ljava/lang/String; public static final field FLAG_MESSAGE Ljava/lang/String; public static final field FREEZE_CHANNEL Ljava/lang/String; public static final field INSTANCE Lio/getstream/chat/android/models/ChannelCapabilities; diff --git a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt index b0a2690f7a6..e097ce44245 100644 --- a/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt +++ b/stream-chat-android-core/src/main/java/io/getstream/chat/android/models/ChannelCapabilities.kt @@ -66,6 +66,9 @@ public object ChannelCapabilities { /** Ability to receive read events. */ public const val READ_EVENTS: String = "read-events" + /** Ability to receive delivery events. */ + public const val DELIVERY_EVENTS: String = "delivery-events" + /** Ability to use message search. */ public const val SEARCH_MESSAGES: String = "search-messages" From eb33b8c95b7679dc8c7de1d664cdb7fd074c41fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Oct 2025 13:01:46 +0000 Subject: [PATCH 44/58] Refactor: Make MessageReceiptManager functions suspend The `MessageReceiptManager` is refactored to use `suspend` functions instead of launching new coroutines from a provided `CoroutineScope`. This simplifies the class by removing the need for an injected scope and improves testability. Key changes: - `markChannelsAsDelivered` and `markMessageAsDelivered` are now `suspend` functions. - The `CoroutineScope` dependency has been removed from `MessageReceiptManager` and its initialization in `ChatClient`. - `MessageDeliveredPlugin` is updated to use `onSuccessSuspend` to call the new suspend functions. - `ChatNotificationsImpl` now launches a coroutine to call the suspend function `markMessageAsDelivered`. - Tests are updated to use `verifyBlocking` for testing suspend functions and to provide the necessary `CoroutineScope` where required. --- .../chat/android/client/ChatClient.kt | 1 - .../client/notifications/ChatNotifications.kt | 2 +- .../client/plugin/MessageDeliveredPlugin.kt | 5 +-- .../client/receipts/MessageReceiptManager.kt | 31 +++++++------------ .../ChatNotificationsImplTest.kt | 7 +++-- .../plugin/MessageDeliveredPluginTest.kt | 6 ++-- .../receipts/MessageReceiptManagerTest.kt | 3 -- 7 files changed, 25 insertions(+), 30 deletions(-) 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 8874de8f81a..84477066a78 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 @@ -335,7 +335,6 @@ internal constructor( private var _repositoryFacade: RepositoryFacade? = null internal val messageReceiptManager = MessageReceiptManager( - scope = userScope, now = now, getCurrentUser = ::getCurrentUser, channelRepository = repositoryFacade, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt index 6eed0784813..1a19067d076 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt @@ -114,7 +114,7 @@ internal class ChatNotificationsImpl( id = pushMessage.messageId, cid = "${pushMessage.channelType}:${pushMessage.channelId}", ) - chatClient.messageReceiptManager.markMessageAsDelivered(message) + scope.launch { chatClient.messageReceiptManager.markMessageAsDelivered(message) } if (notificationConfig.shouldShowNotificationOnPush() && !handler.onPushMessage(pushMessage)) { handlePushMessage(pushMessage) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt index 52862486c45..182b887285d 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt @@ -24,6 +24,7 @@ import io.getstream.chat.android.client.receipts.MessageReceiptManager import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.User import io.getstream.result.Result +import io.getstream.result.onSuccessSuspend /** * A plugin that marks messages as delivered when channels are queried. @@ -34,7 +35,7 @@ internal class MessageDeliveredPlugin( ) : Plugin { override suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { - result.onSuccess { channels -> + result.onSuccessSuspend { channels -> messageReceiptManager.markChannelsAsDelivered(channels) } } @@ -45,7 +46,7 @@ internal class MessageDeliveredPlugin( channelId: String, request: QueryChannelRequest, ) { - result.onSuccess { channel -> + result.onSuccessSuspend { channel -> if (request.pagination() == null) { // only mark as delivered on initial load messageReceiptManager.markChannelsAsDelivered(channels = listOf(channel)) } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index 5023d7ef9ef..0bb4cb0974e 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -31,8 +31,6 @@ import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import java.util.Date /** @@ -40,7 +38,6 @@ import java.util.Date * for later reporting to the server. */ internal class MessageReceiptManager( - private val scope: CoroutineScope, private val now: () -> Date, private val getCurrentUser: () -> User?, private val channelRepository: ChannelRepository, @@ -64,7 +61,7 @@ internal class MessageReceiptManager( * - Is not yet marked as read by the current user * - Is not yet marked as delivered by the current user */ - fun markChannelsAsDelivered(channels: List) { + suspend fun markChannelsAsDelivered(channels: List) { val currentUser = getCurrentUser() ?: run { logger.w { "[markChannelsAsDelivered] Current user is null" } return @@ -86,7 +83,7 @@ internal class MessageReceiptManager( * * @see [markChannelsAsDelivered] for the conditions to mark a message as delivered. */ - fun markMessageAsDelivered(message: Message) { + suspend fun markMessageAsDelivered(message: Message) { val currentUser = getCurrentUser() ?: run { logger.w { "[markMessageAsDelivered] Current user is null" } return @@ -94,15 +91,13 @@ internal class MessageReceiptManager( if (!currentUser.isDeliveryReceiptsEnabled) return - scope.launch { - val channel = getChannel(message.cid) ?: run { - logger.w { "[markMessageAsDelivered] Channel ${message.cid} not found" } - return@launch - } + val channel = getChannel(message.cid) ?: run { + logger.w { "[markMessageAsDelivered] Channel ${message.cid} not found" } + return + } - if (canMarkMessageAsDelivered(currentUser, channel, message)) { - markMessagesAsDelivered(messages = listOf(message)) - } + if (canMarkMessageAsDelivered(currentUser, channel, message)) { + markMessagesAsDelivered(messages = listOf(message)) } } @@ -115,7 +110,7 @@ internal class MessageReceiptManager( .await().getOrNull() } - private fun markMessagesAsDelivered(messages: List) { + private suspend fun markMessagesAsDelivered(messages: List) { if (messages.isEmpty()) { logger.w { "[markMessagesAsDelivered] No receipts to send" } return @@ -123,12 +118,10 @@ internal class MessageReceiptManager( logger.d { "[markMessagesAsDelivered] Processing delivery receipts for ${messages.size} messages…" } - scope.launch { - val receipts = messages.map { message -> message.toDeliveryReceipt() } - messageReceiptRepository.upsertMessageReceipts(receipts) + val receipts = messages.map { message -> message.toDeliveryReceipt() } + messageReceiptRepository.upsertMessageReceipts(receipts) - logger.d { "[markMessagesAsDelivered] ${messages.size} delivery receipts upserted" } - } + logger.d { "[markMessagesAsDelivered] ${messages.size} delivery receipts upserted" } } private fun canMarkMessageAsDelivered( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt index 2593d08b40f..94009270b6a 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt @@ -29,6 +29,8 @@ import io.getstream.chat.android.client.randomPushMessage import io.getstream.chat.android.client.receipts.MessageReceiptManager import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PushMessage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Test import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull @@ -36,6 +38,7 @@ import org.junit.runner.RunWith import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever import org.robolectric.annotation.Config @@ -130,7 +133,7 @@ internal class ChatNotificationsImplTest { } fun verifyMarkMessageAsDeliveredCalled(message: Message) { - verify(mockMessageReceiptManager).markMessageAsDelivered(message) + verifyBlocking(mockMessageReceiptManager) { markMessageAsDelivered(message) } } fun get(): ChatNotificationsImpl { @@ -141,7 +144,7 @@ internal class ChatNotificationsImplTest { handler = mockNotificationHandler, notificationConfig = notificationConfig, context = context, - scope = mock(), + scope = CoroutineScope(UnconfinedTestDispatcher()), chatClientProvider = { mockChatClient }, ) } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt index 3b86b3be71f..91ccdc94aaf 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt @@ -27,7 +27,7 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times -import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyBlocking import org.mockito.verification.VerificationMode internal class MessageDeliveredPluginTest { @@ -109,7 +109,9 @@ internal class MessageDeliveredPluginTest { mode: VerificationMode = times(1), channels: List? = null, ) { - verify(mockMessageReceiptManager, mode).markChannelsAsDelivered(channels ?: any()) + verifyBlocking(mockMessageReceiptManager, mode) { + markChannelsAsDelivered(channels ?: any()) + } } fun get() = MessageDeliveredPlugin( diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index f092d093a09..1f61537b5a7 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -30,8 +30,6 @@ import io.getstream.chat.android.randomMessage import io.getstream.chat.android.randomUser import io.getstream.chat.android.test.asCall import io.getstream.result.Error -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any @@ -345,7 +343,6 @@ internal class MessageReceiptManagerTest { } fun get() = MessageReceiptManager( - scope = CoroutineScope(UnconfinedTestDispatcher()), now = { Now }, getCurrentUser = getCurrentUser, channelRepository = mockChannelRepository, From cf17138bd90175dd6fa184152d0ad7905eec26e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Oct 2025 13:30:15 +0000 Subject: [PATCH 45/58] Refactor: Lazy initialize messageReceiptManager in MessageDeliveredPlugin and ChatClient --- .../getstream/chat/android/client/ChatClient.kt | 16 +++++++++------- .../client/plugin/MessageDeliveredPlugin.kt | 2 +- .../client/plugin/MessageDeliveredPluginTest.kt | 3 +-- 3 files changed, 11 insertions(+), 10 deletions(-) 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 84477066a78..a30a672a7d7 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 @@ -334,13 +334,15 @@ internal constructor( private var _repositoryFacade: RepositoryFacade? = null - internal val messageReceiptManager = MessageReceiptManager( - now = now, - getCurrentUser = ::getCurrentUser, - channelRepository = repositoryFacade, - messageReceiptRepository = repository, - api = api, - ) + internal val messageReceiptManager by lazy { + MessageReceiptManager( + now = now, + getCurrentUser = ::getCurrentUser, + channelRepository = repositoryFacade, + messageReceiptRepository = repository, + api = api, + ) + } private var pushNotificationReceivedListener: PushNotificationReceivedListener = PushNotificationReceivedListener { _, _ -> } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt index 182b887285d..db1eb8ca6de 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/plugin/MessageDeliveredPlugin.kt @@ -31,8 +31,8 @@ import io.getstream.result.onSuccessSuspend */ internal class MessageDeliveredPlugin( chatClient: ChatClient = ChatClient.instance(), - private val messageReceiptManager: MessageReceiptManager = chatClient.messageReceiptManager, ) : Plugin { + private val messageReceiptManager: MessageReceiptManager by lazy { chatClient.messageReceiptManager } override suspend fun onQueryChannelsResult(result: Result>, request: QueryChannelsRequest) { result.onSuccessSuspend { channels -> diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt index 91ccdc94aaf..0fede51a012 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/plugin/MessageDeliveredPluginTest.kt @@ -115,8 +115,7 @@ internal class MessageDeliveredPluginTest { } fun get() = MessageDeliveredPlugin( - chatClient = mock(), - messageReceiptManager = mockMessageReceiptManager, + chatClient = mock { on { messageReceiptManager } doReturn mockMessageReceiptManager }, ) } } From 6a6952a84419d9057e204c241a135deba0f3082c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Oct 2025 15:35:38 +0000 Subject: [PATCH 46/58] Fix: Fetch message from API when marking as delivered by ID When `markMessageAsDelivered` is called with a `messageId`, the SDK now fetches the message from the local repository. If the message is not found locally, it falls back to fetching it from the API. This ensures that a delivery receipt can be sent for a push notification even if the message is not yet in the local database. --- .../chat/android/client/ChatClient.kt | 2 +- .../client/notifications/ChatNotifications.kt | 7 +-- .../client/receipts/MessageReceiptManager.kt | 41 +++++++++---- .../ChatNotificationsImplTest.kt | 11 +--- .../receipts/MessageReceiptManagerTest.kt | 59 ++++++++++++++++--- 5 files changed, 86 insertions(+), 34 deletions(-) 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 a30a672a7d7..d6b389a4f03 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 @@ -338,7 +338,7 @@ internal constructor( MessageReceiptManager( now = now, getCurrentUser = ::getCurrentUser, - channelRepository = repositoryFacade, + repositoryFacade = repositoryFacade, messageReceiptRepository = repository, api = api, ) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt index 1a19067d076..4ed3d986a8b 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt @@ -28,7 +28,6 @@ import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.notifications.handler.NotificationHandler import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Device -import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PushMessage import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger @@ -110,11 +109,7 @@ internal class ChatNotificationsImpl( pushNotificationReceivedListener.onPushNotificationReceived(pushMessage.channelType, pushMessage.channelId) - val message = Message( - id = pushMessage.messageId, - cid = "${pushMessage.channelType}:${pushMessage.channelId}", - ) - scope.launch { chatClient.messageReceiptManager.markMessageAsDelivered(message) } + scope.launch { chatClient.messageReceiptManager.markMessageAsDelivered(pushMessage.messageId) } if (notificationConfig.shouldShowNotificationOnPush() && !handler.onPushMessage(pushMessage)) { handlePushMessage(pushMessage) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index 0bb4cb0974e..a2e67ed697a 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -23,7 +23,7 @@ import io.getstream.chat.android.client.extensions.getCreatedAtOrThrow import io.getstream.chat.android.client.extensions.internal.NEVER import io.getstream.chat.android.client.extensions.internal.lastMessage import io.getstream.chat.android.client.extensions.userRead -import io.getstream.chat.android.client.persistance.repository.ChannelRepository +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.models.Channel @@ -40,7 +40,7 @@ import java.util.Date internal class MessageReceiptManager( private val now: () -> Date, private val getCurrentUser: () -> User?, - private val channelRepository: ChannelRepository, + private val repositoryFacade: RepositoryFacade, private val messageReceiptRepository: MessageReceiptRepository, private val api: ChatApi, ) { @@ -91,7 +91,7 @@ internal class MessageReceiptManager( if (!currentUser.isDeliveryReceiptsEnabled) return - val channel = getChannel(message.cid) ?: run { + val channel = retrieveChannel(message.cid) ?: run { logger.w { "[markMessageAsDelivered] Channel ${message.cid} not found" } return } @@ -101,14 +101,33 @@ internal class MessageReceiptManager( } } - private suspend fun getChannel(cid: String): Channel? = - channelRepository.selectChannel(cid) - ?: run { - val (channelType, channelId) = cid.cidToTypeAndId() - val request = QueryChannelRequest() - api.queryChannel(channelType, channelId, request) - .await().getOrNull() - } + /** + * Request to mark the message with the given id as delivered. + * + * @see [markChannelsAsDelivered] for the conditions to mark a message as delivered. + */ + suspend fun markMessageAsDelivered(messageId: String) { + val message = retrieveMessage(messageId) ?: run { + logger.w { "[markMessageAsDelivered] Message $messageId not found" } + return + } + + markMessageAsDelivered(message) + } + + private suspend fun retrieveChannel(cid: String): Channel? = + repositoryFacade.selectChannel(cid) ?: run { + val (channelType, channelId) = cid.cidToTypeAndId() + val request = QueryChannelRequest() + api.queryChannel(channelType, channelId, request) + .await().getOrNull() + } + + private suspend fun retrieveMessage(id: String): Message? = + repositoryFacade.selectMessage(id) ?: run { + api.getMessage(id) + .await().getOrNull() + } private suspend fun markMessagesAsDelivered(messages: List) { if (messages.isEmpty()) { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt index 94009270b6a..9d7e87a14c8 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/ChatNotificationsImplTest.kt @@ -27,7 +27,6 @@ import io.getstream.chat.android.client.notifications.handler.NotificationConfig import io.getstream.chat.android.client.notifications.handler.NotificationHandler import io.getstream.chat.android.client.randomPushMessage import io.getstream.chat.android.client.receipts.MessageReceiptManager -import io.getstream.chat.android.models.Message import io.getstream.chat.android.models.PushMessage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -69,11 +68,7 @@ internal class ChatNotificationsImplTest { sut.onPushMessage(pushMessage) - val message = Message( - id = pushMessage.messageId, - cid = "${pushMessage.channelType}:${pushMessage.channelId}", - ) - fixture.verifyMarkMessageAsDeliveredCalled(message) + fixture.verifyMarkMessageAsDeliveredCalled(messageId = pushMessage.messageId) } @Test @@ -132,8 +127,8 @@ internal class ChatNotificationsImplTest { notificationConfig = config } - fun verifyMarkMessageAsDeliveredCalled(message: Message) { - verifyBlocking(mockMessageReceiptManager) { markMessageAsDelivered(message) } + fun verifyMarkMessageAsDeliveredCalled(messageId: String) { + verifyBlocking(mockMessageReceiptManager) { markMessageAsDelivered(messageId) } } fun get(): ChatNotificationsImpl { diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index 1f61537b5a7..5040a883254 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -20,7 +20,7 @@ import io.getstream.chat.android.DeliveryReceipts import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.client.api.ChatApi import io.getstream.chat.android.client.extensions.internal.NEVER -import io.getstream.chat.android.client.persistance.repository.ChannelRepository +import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository import io.getstream.chat.android.models.User import io.getstream.chat.android.randomChannel @@ -64,10 +64,10 @@ internal class MessageReceiptManagerTest { } @Test - fun `store message delivery receipt when channel is found from API`() = runTest { + fun `fetch channel from API when channel is not found from repository`() = runTest { val message = DeliverableMessage val fixture = Fixture() - .givenChannelNotFromRepository() + .givenChannelNotFoundFromRepository() val sut = fixture.get() sut.markMessageAsDelivered(message) @@ -83,11 +83,44 @@ internal class MessageReceiptManagerTest { fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) } + @Test + fun `fetch message from API when message is not found from repository`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + .givenMessageNotFoundFromRepository() + val sut = fixture.get() + + sut.markMessageAsDelivered(messageId = message.id) + + val receipts = listOf( + MessageReceipt( + messageId = message.id, + type = MessageReceipt.TYPE_DELIVERY, + createdAt = Now, + cid = message.cid, + ), + ) + fixture.verifyUpsertMessageReceiptsCalled(receipts = receipts) + } + + @Test + fun `should skip storing message delivery receipt when message is not found`() = runTest { + val message = DeliverableMessage + val fixture = Fixture() + .givenMessageNotFoundFromRepository() + .givenMessageNotFoundFromApi() + val sut = fixture.get() + + sut.markMessageAsDelivered(messageId = message.id) + + fixture.verifyUpsertMessageReceiptsCalled(never()) + } + @Test fun `should skip storing message delivery receipt when channel is not found`() = runTest { val message = DeliverableMessage val fixture = Fixture() - .givenChannelNotFromRepository() + .givenChannelNotFoundFromRepository() .givenChannelNotFoundFromApi() val sut = fixture.get() @@ -301,8 +334,9 @@ internal class MessageReceiptManagerTest { private class Fixture { private var getCurrentUser: () -> User? = { CurrentUser } - private val mockChannelRepository = mock { + private val mockRepositoryFacade = mock { onBlocking { selectChannel(DeliverableChannel.cid) } doReturn DeliverableChannel + onBlocking { selectMessage(DeliverableMessage.id) } doReturn DeliverableMessage } private val mockMessageReceiptRepository = mock() private val mockChatApi = mock { @@ -313,14 +347,19 @@ internal class MessageReceiptManagerTest { query = any(), ) } doReturn DeliverableChannel.asCall() + on { getMessage(messageId = DeliverableMessage.id) } doReturn DeliverableMessage.asCall() } fun givenCurrentUser(user: User?) = apply { getCurrentUser = { user } } - fun givenChannelNotFromRepository() = apply { - wheneverBlocking { mockChannelRepository.selectChannel(cid = any()) } doReturn null + fun givenChannelNotFoundFromRepository() = apply { + wheneverBlocking { mockRepositoryFacade.selectChannel(cid = any()) } doReturn null + } + + fun givenMessageNotFoundFromRepository() = apply { + wheneverBlocking { mockRepositoryFacade.selectMessage(messageId = any()) } doReturn null } fun givenChannelNotFoundFromApi() = apply { @@ -333,6 +372,10 @@ internal class MessageReceiptManagerTest { } doReturn mock().asCall() } + fun givenMessageNotFoundFromApi() = apply { + wheneverBlocking { mockChatApi.getMessage(messageId = any()) } doReturn mock().asCall() + } + fun verifyUpsertMessageReceiptsCalled( mode: VerificationMode = times(1), receipts: List? = null, @@ -345,7 +388,7 @@ internal class MessageReceiptManagerTest { fun get() = MessageReceiptManager( now = { Now }, getCurrentUser = getCurrentUser, - channelRepository = mockChannelRepository, + repositoryFacade = mockRepositoryFacade, messageReceiptRepository = mockMessageReceiptRepository, api = mockChatApi, ) From ec9d8684229201867d419ede520822bff098c610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Oct 2025 16:18:06 +0000 Subject: [PATCH 47/58] Skip sending delivery receipts for shadowed messages and muted users This change prevents sending message delivery receipts for messages that are either shadowed or sent by a user who has been muted by the current user. --- .../client/receipts/MessageReceiptManager.kt | 18 ++++++++---------- .../receipts/MessageReceiptManagerTest.kt | 13 +++++++++---- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt index a2e67ed697a..fc8824da9b3 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/receipts/MessageReceiptManager.kt @@ -25,10 +25,8 @@ import io.getstream.chat.android.client.extensions.internal.lastMessage import io.getstream.chat.android.client.extensions.userRead import io.getstream.chat.android.client.persistance.repository.RepositoryFacade import io.getstream.chat.android.client.persistence.repository.MessageReceiptRepository -import io.getstream.chat.android.client.utils.message.isDeleted import io.getstream.chat.android.models.Channel import io.getstream.chat.android.models.Message -import io.getstream.chat.android.models.MessageType import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger import java.util.Date @@ -56,8 +54,8 @@ internal class MessageReceiptManager( * * A delivery message candidate is the last non-deleted message in the channel that: * - It was not sent by the current user - * - It is not a system message - * - It is not deleted + * - Is not shadow banned + * - Was not sent by a muted user * - Is not yet marked as read by the current user * - Is not yet marked as delivered by the current user */ @@ -162,15 +160,15 @@ internal class MessageReceiptManager( return false } - // Do not send delivery receipts for system messages - if (message.type == MessageType.SYSTEM) { - logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is a system message" } + // Do not send delivery receipts for shadowed messages + if (message.shadowed) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is shadowed" } return false } - // Do not send delivery receipts for deleted messages - if (message.isDeleted()) { - logger.w { "[canMarkMessageAsDelivered] Message ${message.id} is deleted" } + // Do not send delivery receipts for messages sent by muted users + if (currentUser.mutes.any { mute -> mute.target?.id == message.user.id }) { + logger.w { "[canMarkMessageAsDelivered] Message ${message.id} was sent by a muted user ${message.user.id}" } return false } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index 5040a883254..c59b00048f1 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -27,6 +27,7 @@ import io.getstream.chat.android.randomChannel import io.getstream.chat.android.randomChannelUserRead import io.getstream.chat.android.randomConfig import io.getstream.chat.android.randomMessage +import io.getstream.chat.android.randomMute import io.getstream.chat.android.randomUser import io.getstream.chat.android.test.asCall import io.getstream.result.Error @@ -188,8 +189,8 @@ internal class MessageReceiptManagerTest { } @Test - fun `should skip storing message delivery receipt from system messages`() = runTest { - val message = DeliverableMessage.copy(type = "system") + fun `should skip storing message delivery receipt from shadow banned messages`() = runTest { + val message = DeliverableMessage.copy(shadowed = true) val fixture = Fixture() val sut = fixture.get() @@ -199,9 +200,13 @@ internal class MessageReceiptManagerTest { } @Test - fun `should skip storing message delivery receipt from deleted messages`() = runTest { - val message = DeliverableMessage.copy(deletedAt = Date()) + fun `should skip storing message delivery receipt from muted users`() = runTest { + val message = DeliverableMessage + val currentUser = CurrentUser.copy( + mutes = listOf(randomMute(user = CurrentUser, target = message.user)) + ) val fixture = Fixture() + .givenCurrentUser(currentUser) val sut = fixture.get() sut.markMessageAsDelivered(message) From 0e4f258ababcefe3da1059ae4a7c869adc514a23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Fri, 31 Oct 2025 16:53:23 +0000 Subject: [PATCH 48/58] Add deliveryEventsEnabled to channel configuration and mapping --- .../getstream/chat/android/client/api2/mapping/DomainMapping.kt | 1 + .../getstream/chat/android/client/api2/model/dto/ConfigDto.kt | 2 +- .../chat/android/client/api2/mapping/DomainMappingTest.kt | 1 + .../chat/android/client/receipts/MessageReceiptManagerTest.kt | 2 +- .../domain/channelconfig/internal/ChannelConfigEntity.kt | 1 + .../domain/channelconfig/internal/ChannelConfigMapper.kt | 2 ++ .../android/offline/repository/ChannelConfigRepositoryTest.kt | 1 + 7 files changed, 8 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index e270b8fba1c..c78b0ecb013 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -603,6 +603,7 @@ internal class DomainMapping( name = name ?: "", typingEventsEnabled = typing_events, readEventsEnabled = read_events, + deliveryEventsEnabled = delivery_events, connectEventsEnabled = connect_events, searchEnabled = search, isReactionsEnabled = reactions, diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt index 7d728fa7559..cdddec56cda 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/model/dto/ConfigDto.kt @@ -26,7 +26,7 @@ internal data class ConfigDto( val name: String?, val typing_events: Boolean, val read_events: Boolean, - val delivery_events: Boolean?, + val delivery_events: Boolean = true, val connect_events: Boolean, val search: Boolean, val reactions: Boolean, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt index 89370f19131..1109985764e 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/api2/mapping/DomainMappingTest.kt @@ -591,6 +591,7 @@ internal class DomainMappingTest { name = configDto.name ?: "", typingEventsEnabled = configDto.typing_events, readEventsEnabled = configDto.read_events, + deliveryEventsEnabled = configDto.delivery_events, connectEventsEnabled = configDto.connect_events, searchEnabled = configDto.search, isReactionsEnabled = configDto.reactions, diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt index c59b00048f1..0c0a351df86 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/receipts/MessageReceiptManagerTest.kt @@ -203,7 +203,7 @@ internal class MessageReceiptManagerTest { fun `should skip storing message delivery receipt from muted users`() = runTest { val message = DeliverableMessage val currentUser = CurrentUser.copy( - mutes = listOf(randomMute(user = CurrentUser, target = message.user)) + mutes = listOf(randomMute(user = CurrentUser, target = message.user)), ) val fixture = Fixture() .givenCurrentUser(currentUser) diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt index 8849d5bd985..83d9a2250b2 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigEntity.kt @@ -33,6 +33,7 @@ internal data class ChannelConfigInnerEntity( val name: String, val isTypingEvents: Boolean, val isReadEvents: Boolean, + val deliveryEventsEnabled: Boolean, val isConnectEvents: Boolean, val isSearch: Boolean, val isReactionsEnabled: Boolean, diff --git a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigMapper.kt b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigMapper.kt index 52efc1b5fae..d4f9d2ddef1 100644 --- a/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigMapper.kt +++ b/stream-chat-android-offline/src/main/java/io/getstream/chat/android/offline/repository/domain/channelconfig/internal/ChannelConfigMapper.kt @@ -29,6 +29,7 @@ internal fun ChannelConfig.toEntity(): ChannelConfigEntity = ChannelConfigEntity name = name, isTypingEvents = typingEventsEnabled, isReadEvents = readEventsEnabled, + deliveryEventsEnabled = deliveryEventsEnabled, isConnectEvents = connectEventsEnabled, isSearch = searchEnabled, isReactionsEnabled = isReactionsEnabled, @@ -59,6 +60,7 @@ internal fun ChannelConfigEntity.toModel(): ChannelConfig = ChannelConfig( name = name, typingEventsEnabled = isTypingEvents, readEventsEnabled = isReadEvents, + deliveryEventsEnabled = deliveryEventsEnabled, connectEventsEnabled = isConnectEvents, searchEnabled = isSearch, isReactionsEnabled = isReactionsEnabled, diff --git a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ChannelConfigRepositoryTest.kt b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ChannelConfigRepositoryTest.kt index a286eb0965b..a189cf9318e 100644 --- a/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ChannelConfigRepositoryTest.kt +++ b/stream-chat-android-offline/src/test/java/io/getstream/chat/android/offline/repository/ChannelConfigRepositoryTest.kt @@ -136,6 +136,7 @@ internal class ChannelConfigRepositoryTest { isMutes = randomBoolean(), isReactionsEnabled = randomBoolean(), isReadEvents = randomBoolean(), + deliveryEventsEnabled = randomBoolean(), isSearch = randomBoolean(), isThreadEnabled = randomBoolean(), isTypingEvents = randomBoolean(), From 0e0f904ca02682ee49eefc44f4714cdbdc722394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 3 Nov 2025 11:46:54 +0000 Subject: [PATCH 49/58] Add message info option to message menu --- .../DeleteMessageForMeComponentFactory.kt | 6 +- .../component/MessageInfoComponentFactory.kt | 104 ++++++++++++++++++ .../src/main/res/values/strings.xml | 1 + 3 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/DeleteMessageForMeComponentFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/DeleteMessageForMeComponentFactory.kt index 92dbf040526..de890cda054 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/DeleteMessageForMeComponentFactory.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/DeleteMessageForMeComponentFactory.kt @@ -45,7 +45,9 @@ import io.getstream.chat.android.ui.common.utils.canDeleteMessage /** * Factory for creating components related to deleting messages for the current user. */ -class DeleteMessageForMeComponentFactory : ChatComponentFactory { +class DeleteMessageForMeComponentFactory( + private val delegate: ChatComponentFactory = MessageInfoComponentFactory(), +) : ChatComponentFactory by delegate { /** * Creates a message menu with option for deleting messages for the current user. @@ -117,7 +119,7 @@ class DeleteMessageForMeComponentFactory : ChatComponentFactory { ) } - super.MessageMenu( + delegate.MessageMenu( modifier = modifier, message = message, messageOptions = allOptions, diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt new file mode 100644 index 00000000000..5252c0ef4cf --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.sample.ui.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.state.messageoptions.MessageOptionItemState +import io.getstream.chat.android.compose.ui.theme.ChatComponentFactory +import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.models.Message +import io.getstream.chat.android.ui.common.state.messages.CustomAction +import io.getstream.chat.android.ui.common.state.messages.MessageAction + +/** + * Factory for creating components related to message info. + */ +class MessageInfoComponentFactory : ChatComponentFactory { + + /** + * Creates a message menu with option for message info. + */ + @OptIn(ExperimentalMaterial3Api::class) + @Suppress("LongMethod") + @Composable + override fun MessageMenu( + modifier: Modifier, + message: Message, + messageOptions: List, + ownCapabilities: Set, + onMessageAction: (MessageAction) -> Unit, + onShowMore: () -> Unit, + onDismiss: () -> Unit, + ) { + var showMessageInfoDialog by remember { mutableStateOf(false) } + + val allOptions = listOf( + MessageOptionItemState( + title = R.string.message_option_message_info, + titleColor = ChatTheme.colors.textHighEmphasis, + iconPainter = rememberVectorPainter(Icons.Outlined.Info), + iconColor = ChatTheme.colors.textLowEmphasis, + action = CustomAction(message, mapOf("message_info" to true)), + ) + ) + messageOptions + + val extendedOnMessageAction: (MessageAction) -> Unit = { action -> + when { + action is CustomAction && action.extraProperties.contains("message_info") -> + showMessageInfoDialog = true + + else -> onMessageAction(action) + } + } + + var dismissed by remember { mutableStateOf(false) } + + if (showMessageInfoDialog) { + ModalBottomSheet( + onDismissRequest = { + showMessageInfoDialog = false + onDismiss() + dismissed = true // Mark as dismissed to avoid animating the menu again + }, + containerColor = ChatTheme.colors.appBackground, + ) { + // TODO Replace with a proper Message Info Screen + } + } else if (!dismissed) { + super.MessageMenu( + modifier = modifier, + message = message, + messageOptions = allOptions, + ownCapabilities = ownCapabilities, + onMessageAction = extendedOnMessageAction, + onShowMore = onShowMore, + onDismiss = onDismiss, + ) + } + } +} diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index e1722ecd422..0f1639c8656 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -122,6 +122,7 @@ Delete Message For Me + Message Info Failed to load more media attachments From 30f08f5d6561d4deeb1bb25279580a5cee60657a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 3 Nov 2025 15:10:39 +0000 Subject: [PATCH 50/58] Extra PaneTitle and PaneRow components for reusing --- .../compose/sample/ui/component/Pane.kt | 84 +++++++++++++++++++ .../sample/ui/profile/UserProfileScreen.kt | 55 +----------- 2 files changed, 86 insertions(+), 53 deletions(-) create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/Pane.kt diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/Pane.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/Pane.kt new file mode 100644 index 00000000000..4d937355e56 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/Pane.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.sample.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +@Composable +internal fun PaneTitle( + text: String, + padding: PaddingValues = PaddingValues( + top = 24.dp, + start = 16.dp, + bottom = 8.dp, + end = 16.dp, + ), +) { + Text( + modifier = Modifier.padding(padding), + text = text, + style = ChatTheme.typography.footnote, + color = ChatTheme.colors.textLowEmphasis, + ) +} + +@Composable +internal fun PaneRow( + index: Int, + lastIndex: Int, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .run { + val shape = when (index) { + 0 -> if (lastIndex == 0) { + // Single item in the list + RoundedCornerShape(12.dp) + } else { + // Top item in the list + RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) + } + // Bottom item in the list + lastIndex -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) + // Middle item in the list + else -> RectangleShape + } + background( + color = ChatTheme.colors.barsBackground, + shape = shape, + ) + } + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + content = content, + ) +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt index 2393a4f47b6..361daa3a6a6 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt @@ -28,7 +28,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -81,6 +80,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.sample.ui.component.PaneRow +import io.getstream.chat.android.compose.sample.ui.component.PaneTitle import io.getstream.chat.android.compose.ui.components.BackButton import io.getstream.chat.android.compose.ui.components.LoadingIndicator import io.getstream.chat.android.compose.ui.components.avatar.UserAvatar @@ -752,58 +753,6 @@ private fun LazyListScope.unreadChannelsByTeam( } } -@Composable -private fun PaneTitle( - text: String, - padding: PaddingValues = PaddingValues( - top = 24.dp, - start = 16.dp, - bottom = 8.dp, - end = 16.dp, - ), -) { - Text( - modifier = Modifier.padding(padding), - text = text, - style = ChatTheme.typography.footnote, - color = ChatTheme.colors.textLowEmphasis, - ) -} - -@Composable -private fun PaneRow( - index: Int, - lastIndex: Int, - content: @Composable RowScope.() -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .run { - val shape = when (index) { - 0 -> if (lastIndex == 0) { - // Single item in the list - RoundedCornerShape(12.dp) - } else { - // Top item in the list - RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) - } - // Bottom item in the list - lastIndex -> RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp) - // Middle item in the list - else -> RectangleShape - } - background( - color = ChatTheme.colors.barsBackground, - shape = shape, - ) - } - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - content = content, - ) -} - @Composable private fun CountText( count: Int, From a8e92153aaedba33cbf5842c1bc4bfac8c2e268f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 3 Nov 2025 15:12:22 +0000 Subject: [PATCH 51/58] Add message info component to display read and delivered status --- .../component/MessageInfoComponentFactory.kt | 212 +++++++++++++++++- .../src/main/res/values/strings.xml | 4 + 2 files changed, 214 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt index 5252c0ef4cf..508d22c92fa 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt @@ -16,24 +16,61 @@ package io.getstream.chat.android.compose.sample.ui.component +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.extensions.deliveredReadsOf +import io.getstream.chat.android.client.extensions.readsOf import io.getstream.chat.android.compose.sample.R +import io.getstream.chat.android.compose.state.DateFormatType import io.getstream.chat.android.compose.state.messageoptions.MessageOptionItemState +import io.getstream.chat.android.compose.ui.components.Timestamp +import io.getstream.chat.android.compose.ui.components.avatar.Avatar import io.getstream.chat.android.compose.ui.theme.ChatComponentFactory import io.getstream.chat.android.compose.ui.theme.ChatTheme +import io.getstream.chat.android.models.Channel +import io.getstream.chat.android.models.ChannelUserRead import io.getstream.chat.android.models.Message +import io.getstream.chat.android.models.User +import io.getstream.chat.android.state.extensions.watchChannelAsState import io.getstream.chat.android.ui.common.state.messages.CustomAction import io.getstream.chat.android.ui.common.state.messages.MessageAction +import io.getstream.chat.android.ui.common.utils.extensions.initials +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import java.util.Calendar +import java.util.Date +import kotlin.time.Duration.Companion.hours /** * Factory for creating components related to message info. @@ -64,7 +101,7 @@ class MessageInfoComponentFactory : ChatComponentFactory { iconPainter = rememberVectorPainter(Icons.Outlined.Info), iconColor = ChatTheme.colors.textLowEmphasis, action = CustomAction(message, mapOf("message_info" to true)), - ) + ), ) + messageOptions val extendedOnMessageAction: (MessageAction) -> Unit = { action -> @@ -87,7 +124,15 @@ class MessageInfoComponentFactory : ChatComponentFactory { }, containerColor = ChatTheme.colors.appBackground, ) { - // TODO Replace with a proper Message Info Screen + val coroutineScope = rememberCoroutineScope() + val state by readsOf(message, coroutineScope).collectAsState(null) + state?.let { + val (reads, deliveredReads) = it + MessageInfoContent( + reads = reads, + deliveredReads = deliveredReads, + ) + } } } else if (!dismissed) { super.MessageMenu( @@ -101,4 +146,167 @@ class MessageInfoComponentFactory : ChatComponentFactory { ) } } + + @Composable + private fun readsOf( + message: Message, + coroutineScope: CoroutineScope, + ): Flow, List>> = ChatClient.instance() + .watchChannelAsState( + cid = message.cid, + messageLimit = 0, + coroutineScope = coroutineScope, + ).filterNotNull() + .flatMapLatest { it.reads } + .map { + val channel = Channel(read = it) + + val reads = channel.readsOf(message) + .sortedByDescending(ChannelUserRead::lastRead) + + val deliveredReads = channel.deliveredReadsOf(message) + .sortedByDescending { it.lastDeliveredAt ?: Date(0) } - reads + + reads to deliveredReads + } +} + +@Composable +private fun MessageInfoContent( + reads: List, + deliveredReads: List, +) { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + ), + ) { + // Read by section + section( + items = reads, + labelResId = R.string.message_info_read_by, + skipTopPadding = true, + ) + // Delivered to section + section( + items = deliveredReads, + labelResId = R.string.message_info_delivered_to, + ) + } +} + +private fun LazyListScope.section( + items: List, + @StringRes labelResId: Int, + skipTopPadding: Boolean = false, +) { + if (items.isNotEmpty()) { + item { + if (skipTopPadding) { + PaneTitle( + text = stringResource(labelResId, items.size), + padding = PaddingValues( + start = 16.dp, + bottom = 8.dp, + end = 16.dp, + ), + ) + } else { + PaneTitle(text = stringResource(labelResId, items.size)) + } + } + itemsIndexed( + items = items, + key = { _, item -> item.user.id }, + ) { index, item -> + PaneRow( + index = index, + lastIndex = items.lastIndex, + ) { + ReadItem(userRead = item) + } + } + } +} + +@Composable +private fun ReadItem( + userRead: ChannelUserRead, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Avatar( + modifier = Modifier.size(48.dp), + imageUrl = userRead.user.image, + initials = userRead.user.initials, + contentDescription = userRead.user.name, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = userRead.user.name.takeIf(String::isNotBlank) ?: userRead.user.id, + style = ChatTheme.typography.bodyBold, + color = ChatTheme.colors.textHighEmphasis, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Timestamp( + date = userRead.lastDeliveredAt ?: userRead.lastRead, + formatType = DateFormatType.RELATIVE, + ) + } + } +} + +@Suppress("MagicNumber") +@Preview +@Composable +private fun MessageInfoScreenPreview() { + val sentDate = Calendar.getInstance().apply { + set(2025, Calendar.AUGUST, 15, 8, 15) + }.time + val user1 = User(id = "jane", name = "Jane Doe") + val user2 = User(id = "bob", name = "Bob Smith") + val user3 = User(id = "alice", name = "Alice Johnson") + val reads = listOf( + ChannelUserRead( + user = user1, + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = sentDate.apply { time += 2.hours.inWholeMilliseconds }, + lastReadMessageId = null, + ), + ChannelUserRead( + user = user2, + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = sentDate.apply { time += 3.hours.inWholeMilliseconds }, + lastReadMessageId = null, + ), + ) + val deliveredReads = listOf( + ChannelUserRead( + user = user3, + lastReceivedEventDate = Date(), + unreadMessages = 0, + lastRead = Date(), + lastReadMessageId = null, + lastDeliveredAt = sentDate.apply { time += 1.hours.inWholeMilliseconds }, + lastDeliveredMessageId = null, + ), + ) + ChatTheme { + MessageInfoContent( + deliveredReads = deliveredReads, + reads = reads, + ) + } } diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index 0f1639c8656..02bbab01a2f 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -128,4 +128,8 @@ Failed to load more media attachments Failed to load more files attachments + + READ BY (%d) + DELIVERY TO (%d) + From 9bc107e2c8b89f1c6c3e25ff35511ab81798dd31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 3 Nov 2025 15:19:46 +0000 Subject: [PATCH 52/58] Stop using kluent assertions --- .../chat/android/client/ChatClientTest.kt | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt index d2e01453e0c..5dc492b956f 100644 --- a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/ChatClientTest.kt @@ -51,7 +51,8 @@ import io.getstream.result.Error import io.getstream.result.Result import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest -import org.amshove.kluent.shouldBeEqualTo +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -157,8 +158,8 @@ internal class ChatClientTest { val channelId = randomString() val channelClient = client.channel(channelType, channelId) - channelClient.channelType shouldBeEqualTo channelType - channelClient.channelId shouldBeEqualTo channelId + assertEquals(channelType, channelClient.channelType) + assertEquals(channelId, channelClient.channelId) } @Test @@ -167,8 +168,8 @@ internal class ChatClientTest { val channelClient = client.channel(cid) val (type, id) = cid.cidToTypeAndId() - channelClient.channelType shouldBeEqualTo type - channelClient.channelId shouldBeEqualTo id + assertEquals(type, channelClient.channelType) + assertEquals(id, channelClient.channelId) } @Test @@ -179,7 +180,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) - result shouldBeEqualTo listOf(eventB) + assertEquals(listOf(eventB), result) } @Test @@ -192,7 +193,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventA, eventB, eventC) + assertEquals(listOf(eventA, eventB, eventC), result) } @Test @@ -207,7 +208,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventE) fakeChatSocket.mockEventReceived(eventD) - result shouldBeEqualTo listOf(eventD, eventF, eventD) + assertEquals(listOf(eventD, eventF, eventD), result) } @Test @@ -220,7 +221,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventA, eventC) + assertEquals(listOf(eventA, eventC), result) } @Test @@ -233,7 +234,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventA, eventC) + assertEquals(listOf(eventA, eventC), result) } @Test @@ -246,7 +247,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventD) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventD) + assertEquals(listOf(eventD), result) } @Test @@ -260,7 +261,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventD) fakeChatSocket.mockEventReceived(eventE) - result shouldBeEqualTo listOf(eventD) + assertEquals(listOf(eventD), result) } @Test @@ -273,7 +274,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventD) fakeChatSocket.mockEventReceived(eventE) - result shouldBeEqualTo listOf(eventD) + assertEquals(listOf(eventD), result) } @Test @@ -289,7 +290,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(eventB) fakeChatSocket.mockEventReceived(eventC) - result shouldBeEqualTo listOf(eventA) + assertEquals(listOf(eventA), result) } @Test @@ -301,7 +302,7 @@ internal class ChatClientTest { fakeChatSocket.mockEventReceived(Mother.randomUserPresenceChangedEvent(user = updateUser)) - client.getCurrentUser() shouldBeEqualTo updateUser + assertEquals(updateUser, client.getCurrentUser()) } @Test @@ -335,7 +336,7 @@ internal class ChatClientTest { fakeChatSocket.verifySocketFactory { verify(it, times(1)).createSocket(any()) } - client.clientState.connectionState.value shouldBeEqualTo ConnectionState.Offline + assertEquals(ConnectionState.Offline, client.clientState.connectionState.value) } @Test @@ -363,12 +364,15 @@ internal class ChatClientTest { val result = client.reconnectSocket().await() /* Then */ - result shouldBeEqualTo Result.Failure( - value = Error.GenericError(message = "Invalid user state NotSet without user being set!"), + assertEquals( + Result.Failure( + value = Error.GenericError(message = "Invalid user state NotSet without user being set!"), + ), + result, ) - client.getCurrentUser() shouldBeEqualTo null - client.clientState.user.value shouldBeEqualTo null - client.clientState.connectionState.value shouldBeEqualTo ConnectionState.Offline - client.clientState.initializationState.value shouldBeEqualTo InitializationState.NOT_INITIALIZED + assertNull(client.getCurrentUser()) + assertNull(client.clientState.user.value) + assertEquals(ConnectionState.Offline, client.clientState.connectionState.value) + assertEquals(InitializationState.NOT_INITIALIZED, client.clientState.initializationState.value) } } From 2623af5e912a888024ef3005ae75af82c46fe16d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 3 Nov 2025 15:21:41 +0000 Subject: [PATCH 53/58] Update read and delivered status checks to include equal comparison --- .../chat/android/client/extensions/ChannelExtension.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt index 574eef4596b..6d117c85915 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/extensions/ChannelExtension.kt @@ -144,7 +144,7 @@ public fun Channel.userRead(userId: UserId): ChannelUserRead? = public fun Channel.readsOf(message: Message): List = read.filter { read -> read.user.id != message.user.id && - read.lastRead > message.getCreatedAtOrThrow() + read.lastRead >= message.getCreatedAtOrThrow() } /** @@ -161,5 +161,5 @@ public fun Channel.readsOf(message: Message): List = public fun Channel.deliveredReadsOf(message: Message): List = read.filter { read -> read.user.id != message.user.id && - (read.lastDeliveredAt ?: NEVER) > message.getCreatedAtOrThrow() + (read.lastDeliveredAt ?: NEVER) >= message.getCreatedAtOrThrow() } From 513c2fd4a332717966f002b53a1dff6777c27569 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 3 Nov 2025 16:39:07 +0000 Subject: [PATCH 54/58] Add user profile privacy settings screen --- .../UserProfilePrivacySettingsScreen.kt | 159 ++++++++++++++++++ .../sample/ui/profile/UserProfileScreen.kt | 114 +++++++------ .../sample/ui/profile/UserProfileViewModel.kt | 9 + 3 files changed, 233 insertions(+), 49 deletions(-) create mode 100644 stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfilePrivacySettingsScreen.kt diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfilePrivacySettingsScreen.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfilePrivacySettingsScreen.kt new file mode 100644 index 00000000000..04409a4f1d2 --- /dev/null +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfilePrivacySettingsScreen.kt @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.compose.sample.ui.profile + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import io.getstream.chat.android.DeliveryReceipts +import io.getstream.chat.android.PrivacySettings +import io.getstream.chat.android.ReadReceipts +import io.getstream.chat.android.TypingIndicators +import io.getstream.chat.android.compose.R +import io.getstream.chat.android.compose.ui.theme.ChatTheme + +@Suppress("LongMethod") +@Composable +fun UserProfilePrivacySettingsScreen( + privacySettings: PrivacySettings?, + onSaveClick: (settings: PrivacySettings) -> Unit, +) { + var typingIndicators by remember(privacySettings) { + mutableStateOf(privacySettings?.typingIndicators ?: TypingIndicators()) + } + var deliveryReceipts by remember(privacySettings) { + mutableStateOf(privacySettings?.deliveryReceipts ?: DeliveryReceipts()) + } + var readReceipts by remember(privacySettings) { + mutableStateOf(privacySettings?.readReceipts ?: ReadReceipts()) + } + + Column { + SwitchItem( + label = "Typing Indicators", + checked = typingIndicators.enabled, + onCheckedChange = { checked -> + typingIndicators = typingIndicators.copy(enabled = checked) + }, + ) + SwitchItem( + label = "Delivery Receipts", + checked = deliveryReceipts.enabled, + onCheckedChange = { checked -> + deliveryReceipts = deliveryReceipts.copy(enabled = checked) + }, + ) + SwitchItem( + label = "Read Receipts", + checked = readReceipts.enabled, + onCheckedChange = { checked -> + readReceipts = readReceipts.copy(enabled = checked) + }, + ) + Button( + onClick = { + onSaveClick( + PrivacySettings( + typingIndicators = typingIndicators, + deliveryReceipts = deliveryReceipts, + readReceipts = readReceipts, + ), + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = ChatTheme.colors.primaryAccent, + ), + shape = RoundedCornerShape(12.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(id = R.drawable.stream_compose_ic_checkmark), + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = "Save Settings", + style = ChatTheme.typography.bodyBold.copy( + color = Color.White, + fontSize = 16.sp, + ), + ) + } + } + } +} + +@Composable +private fun SwitchItem( + label: String, + checked: Boolean, + onCheckedChange: (checked: Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = null, + indication = ripple(), + onClick = { onCheckedChange(!checked) }, + ) + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = ChatTheme.typography.title3, + color = ChatTheme.colors.textHighEmphasis, + ) + Switch( + checked = checked, + onCheckedChange = null, + ) + } +} diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt index 361daa3a6a6..ecdd5d58856 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileScreen.kt @@ -152,6 +152,9 @@ fun UserProfileScreen( onUpdateProfilePictureClick = { modalSheet = ModalSheet.UpdateProfilePicture }, + onUpdatePrivacySettingsClick = { + modalSheet = ModalSheet.UpdatePrivacySettings + }, ) } when (modalSheet) { @@ -202,6 +205,21 @@ fun UserProfileScreen( ) } + ModalSheet.UpdatePrivacySettings -> ModalBottomSheet( + onDismissRequest = { modalSheet = null }, + containerColor = ChatTheme.colors.appBackground, + ) { + state.user?.let { user -> + UserProfilePrivacySettingsScreen( + privacySettings = user.privacySettings, + onSaveClick = { settings -> + modalSheet = null + viewModel.updatePrivacySettings(settings) + }, + ) + } + } + null -> Unit } @@ -218,6 +236,7 @@ fun UserProfileScreen( is UserProfileViewEvent.RemoveProfilePictureError, -> snackbarHostState.showSnackbar(message = event.error.message, actionLabel = "Dismiss") + is UserProfileViewEvent.UpdatePushPreferencesError -> { snackbarHostState.showSnackbar(message = event.error.message, actionLabel = "Dismiss") } @@ -284,9 +303,9 @@ private enum class ModalSheet { UnreadCounts, PushPreferences, UpdateProfilePicture, + UpdatePrivacySettings, } -@OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") @Composable private fun UserProfileScreenContent( @@ -295,6 +314,7 @@ private fun UserProfileScreenContent( onUnreadCountsClick: () -> Unit = {}, onPushPreferencesClick: () -> Unit = {}, onUpdateProfilePictureClick: () -> Unit = {}, + onUpdatePrivacySettingsClick: () -> Unit = {}, ) { when (val user = state.user) { null -> { @@ -359,60 +379,56 @@ private fun UserProfileScreenContent( } Divider() } - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = null, - indication = ripple(), - onClick = onUnreadCountsClick, - ) - .padding(start = 16.dp) - .minimumInteractiveComponentSize(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Unread Counts", - style = ChatTheme.typography.title3, - color = ChatTheme.colors.textHighEmphasis, - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = ChatTheme.colors.textLowEmphasis, - ) - } + NavigationItem( + label = "Unread Counts", + onClick = onUnreadCountsClick, + ) Divider() - Row( - modifier = Modifier - .fillMaxWidth() - .clickable( - interactionSource = null, - indication = ripple(), - onClick = onPushPreferencesClick, - ) - .padding(start = 16.dp) - .minimumInteractiveComponentSize(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Push Preferences", - style = ChatTheme.typography.title3, - color = ChatTheme.colors.textHighEmphasis, - ) - Icon( - imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, - contentDescription = null, - tint = ChatTheme.colors.textLowEmphasis, - ) - } + NavigationItem( + label = "Push Preferences", + onClick = onPushPreferencesClick, + ) + Divider() + NavigationItem( + label = "Privacy Settings", + onClick = onUpdatePrivacySettingsClick, + ) } } } } +@Composable +private fun NavigationItem( + label: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable( + interactionSource = null, + indication = ripple(), + onClick = onClick, + ) + .padding(start = 16.dp) + .minimumInteractiveComponentSize(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = ChatTheme.typography.title3, + color = ChatTheme.colors.textHighEmphasis, + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = ChatTheme.colors.textLowEmphasis, + ) + } +} + @Composable private fun UserProfilePicture( modifier: Modifier, diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileViewModel.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileViewModel.kt index 4f544a93a5b..9e582584d79 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileViewModel.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/profile/UserProfileViewModel.kt @@ -18,6 +18,7 @@ package io.getstream.chat.android.compose.sample.ui.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import io.getstream.chat.android.PrivacySettings import io.getstream.chat.android.client.ChatClient import io.getstream.chat.android.client.utils.ProgressCallback import io.getstream.chat.android.models.PushPreferenceLevel @@ -100,6 +101,14 @@ class UserProfileViewModel( } } + fun updatePrivacySettings(settings: PrivacySettings) { + viewModelScope.launch { + val user = state.value.user!! + chatClient.updateUser(user = user.copy(privacySettings = settings)) + .await() + } + } + fun loadUnreadCounts() { _state.update { currentState -> currentState.copy(unreadCounts = null) } viewModelScope.launch { From c586836fe3833db53b21bca1fb537e116df6e109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Mon, 3 Nov 2025 16:40:08 +0000 Subject: [PATCH 55/58] Add privacy settings mapping to domain model --- .../getstream/chat/android/client/api2/mapping/DomainMapping.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt index c78b0ecb013..31ba28582da 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/api2/mapping/DomainMapping.kt @@ -311,6 +311,7 @@ internal class DomainMapping( image = image ?: "", role = role, invisible = invisible, + privacySettings = privacy_settings?.toDomain(), language = language ?: "", banned = banned, devices = devices.orEmpty().map { it.toDomain() }, From 89655f60b66caf6470cf84081f0579ecdc97df5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 4 Nov 2025 10:04:24 +0000 Subject: [PATCH 56/58] Fix message info component to display read and delivered timestamps correctly --- .../ui/component/MessageInfoComponentFactory.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt index 508d22c92fa..69fb3705ced 100644 --- a/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt +++ b/stream-chat-android-compose-sample/src/main/java/io/getstream/chat/android/compose/sample/ui/component/MessageInfoComponentFactory.kt @@ -189,11 +189,13 @@ private fun MessageInfoContent( items = reads, labelResId = R.string.message_info_read_by, skipTopPadding = true, + getDate = ChannelUserRead::lastRead, ) // Delivered to section section( items = deliveredReads, labelResId = R.string.message_info_delivered_to, + getDate = ChannelUserRead::lastDeliveredAt, ) } } @@ -202,6 +204,7 @@ private fun LazyListScope.section( items: List, @StringRes labelResId: Int, skipTopPadding: Boolean = false, + getDate: (ChannelUserRead) -> Date?, ) { if (items.isNotEmpty()) { item { @@ -226,7 +229,10 @@ private fun LazyListScope.section( index = index, lastIndex = items.lastIndex, ) { - ReadItem(userRead = item) + ReadItem( + userRead = item, + getDate = getDate, + ) } } } @@ -235,6 +241,7 @@ private fun LazyListScope.section( @Composable private fun ReadItem( userRead: ChannelUserRead, + getDate: (ChannelUserRead) -> Date?, ) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -259,7 +266,7 @@ private fun ReadItem( ) Timestamp( - date = userRead.lastDeliveredAt ?: userRead.lastRead, + date = getDate(userRead), formatType = DateFormatType.RELATIVE, ) } From 0fbb459b0a21f8a698d81ded3f21c251c142a03a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 4 Nov 2025 14:26:39 +0000 Subject: [PATCH 57/58] typo --- .../src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stream-chat-android-compose-sample/src/main/res/values/strings.xml b/stream-chat-android-compose-sample/src/main/res/values/strings.xml index 02bbab01a2f..8a379a32c59 100644 --- a/stream-chat-android-compose-sample/src/main/res/values/strings.xml +++ b/stream-chat-android-compose-sample/src/main/res/values/strings.xml @@ -130,6 +130,6 @@ READ BY (%d) - DELIVERY TO (%d) + DELIVERED TO (%d) From 7e134c337d0ba6c3c0692fa192f13c5e9c6b6ece Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Mion?= Date: Tue, 4 Nov 2025 14:42:33 +0000 Subject: [PATCH 58/58] CHANGELOG --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fac2c68640f..bd1b97b33cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,12 @@ ### ⬆️ Improved ### ✅ Added +- Introduce `Channel.userRead` extension function to get the read status of a specific user in the channel. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) +- Introduce `Channel.readsOf` extension function to get the read statuses representing which users have read the given message in the channel. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) +- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ⚠️ Changed +- Deprecate `Channel.hasUnread` property in favor of `Channel.currentUserUnreadCount`. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ❌ Removed @@ -49,6 +53,7 @@ ### ⬆️ Improved ### ✅ Added +- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ⚠️ Changed @@ -60,6 +65,7 @@ ### ⬆️ Improved ### ✅ Added +- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ⚠️ Changed @@ -71,6 +77,7 @@ ### ⬆️ Improved ### ✅ Added +- Introduce message delivery receipts. [#5979](https://github.com/GetStream/stream-chat-android/pull/5979) ### ⚠️ Changed @@ -3573,7 +3580,7 @@ The following items are breaking changes, since it was very important to improve - Added `ChatUI.channelNameFormatter` to allow customizing the channel's name format. [#3068](https://github.com/GetStream/stream-chat-android/pull/3068) - Added a customizable height attribute to SearchInputView [#3081](https://github.com/GetStream/stream-chat-android/pull/3081) - Added `ChatUI.dateFormatter` to allow customizing the way the dates are formatted. [#3085](https://github.com/GetStream/stream-chat-android/pull/3085) -- Added ways to show/hide the delivery status indicators for channels and messages. [#3102](https://github.com/GetStream/stream-chat-android/pull/3102) +- Added ways to show/hide the delivery receipts indicators for channels and messages. [#3102](https://github.com/GetStream/stream-chat-android/pull/3102) ### ⚠️ Changed - Disabled editing on Giphy messages given that it's breaking the UX and can override the GIF that was previously put in. [#3071](https://github.com/GetStream/stream-chat-android/pull/3071)