@@ -2,9 +2,7 @@ package com.glia.widgets.chat
22
33import android.text.format.DateUtils
44import androidx.annotation.VisibleForTesting
5- import com.glia.androidsdk.chat.FilesAttachment
65import com.glia.androidsdk.chat.OperatorMessage
7- import com.glia.androidsdk.chat.SendMessagePayload
86import com.glia.androidsdk.chat.SingleChoiceAttachment
97import com.glia.androidsdk.chat.SystemMessage
108import com.glia.androidsdk.chat.VisitorMessage
@@ -23,12 +21,12 @@ import com.glia.widgets.chat.model.ChatItem
2321import com.glia.widgets.chat.model.CustomCardChatItem
2422import com.glia.widgets.chat.model.GvaButton
2523import com.glia.widgets.chat.model.GvaQuickReplies
26- import com.glia.widgets.chat.model.LocalAttachmentItem
2724import com.glia.widgets.chat.model.MediaUpgradeStartedTimerItem
2825import com.glia.widgets.chat.model.NewMessagesDividerItem
2926import com.glia.widgets.chat.model.OperatorChatItem
3027import com.glia.widgets.chat.model.OperatorMessageItem
3128import com.glia.widgets.chat.model.OperatorStatusItem
29+ import com.glia.widgets.chat.model.OutgoingMessage
3230import com.glia.widgets.chat.model.RemoteAttachmentItem
3331import com.glia.widgets.chat.model.TapToRetryItem
3432import com.glia.widgets.chat.model.VisitorAttachmentItem
@@ -47,6 +45,7 @@ import io.reactivex.rxjava3.core.Flowable
4745import io.reactivex.rxjava3.disposables.CompositeDisposable
4846import io.reactivex.rxjava3.disposables.Disposable
4947import io.reactivex.rxjava3.processors.BehaviorProcessor
48+ import io.reactivex.rxjava3.processors.FlowableProcessor
5049import io.reactivex.rxjava3.schedulers.Schedulers
5150
5251internal class ChatManager (
@@ -68,6 +67,10 @@ internal class ChatManager(
6867 private val action : BehaviorProcessor <Action > = BehaviorProcessor .create(),
6968 private val historyLoaded : BehaviorProcessor <Boolean > = BehaviorProcessor .createDefault(false)
7069) {
70+ // Send callbacks invoke onChatAction from Core's background threads - BehaviorProcessor.onNext
71+ // is not thread-safe, so emissions must go through this serialized wrapper.
72+ private val serializedAction: FlowableProcessor <Action > = action.toSerialized()
73+
7174 fun initialize (
7275 onHistoryLoaded : (hasHistory: Boolean ) -> Unit ,
7376 onQuickReplyReceived : (List <GvaButton >) -> Unit ,
@@ -104,7 +107,7 @@ internal class ChatManager(
104107 }
105108
106109 fun onChatAction (action : Action ) {
107- this .action .onNext(action)
110+ serializedAction .onNext(action)
108111 }
109112
110113 @VisibleForTesting
@@ -148,11 +151,19 @@ internal class ChatManager(
148151 .observeOn(AndroidSchedulers .mainThread())
149152 .subscribe { onQuickReplyReceived(it) }
150153
154+ // Core invokes send callbacks and emits CHAT_MESSAGE events on background threads, so the
155+ // send-success path and the message-stream echo can arrive concurrently. State is mutable and
156+ // not thread-safe - hop to the main thread before mapping so `isNew` reconciliation never
157+ // processes the same message twice (which duplicated delivered messages).
151158 @VisibleForTesting
152- fun onMessage (): Flowable <State > = onMessageUseCase().toFlowable(BackpressureStrategy .BUFFER ).withLatestFrom(state, ::mapNewMessage)
159+ fun onMessage (): Flowable <State > = onMessageUseCase().toFlowable(BackpressureStrategy .BUFFER )
160+ .observeOn(AndroidSchedulers .mainThread())
161+ .withLatestFrom(state, ::mapNewMessage)
153162
154163 @VisibleForTesting
155- fun onAction (): Flowable <State > = action.withLatestFrom(state, ::mapAction)
164+ fun onAction (): Flowable <State > = action
165+ .observeOn(AndroidSchedulers .mainThread())
166+ .withLatestFrom(state, ::mapAction)
156167
157168 @VisibleForTesting
158169 fun checkUnsentMessages (state : State ) {
@@ -163,9 +174,9 @@ internal class ChatManager(
163174 sendMessage(payload)
164175 }
165176
166- private fun sendMessage (payload : SendMessagePayload ) {
177+ private fun sendMessage (payload : OutgoingMessage ) {
167178 sendUnsentMessagesUseCase(payload, {
168- onChatAction(Action .OnMessageSent (it ))
179+ onChatAction(Action .OnMessageSent (payload.messageId ))
169180 }, {
170181 onChatAction(Action .OnSendMessageError (payload.messageId))
171182 })
@@ -179,7 +190,7 @@ internal class ChatManager(
179190
180191 for (index in rawItems.indices.reversed()) {
181192 val rawMessage = rawItems[index]
182- if (state.isNew(rawMessage)) {
193+ if (state.isNew(rawMessage.chatMessage.id )) {
183194 appendHistoryChatMessageUseCase(chatItems, rawMessage, index == rawItems.lastIndex)
184195 }
185196 }
@@ -196,11 +207,26 @@ internal class ChatManager(
196207 return state
197208 }
198209
210+ /* *
211+ * Handles a send-API success confirmation: marks the optimistic item with [messageId] as
212+ * delivered and tries the next unsent message. Skipped when the message was already
213+ * reconciled through the incoming message stream.
214+ */
215+ @VisibleForTesting
216+ fun mapMessageSent (messageId : String , messagesState : State ): State {
217+ if (messagesState.isNew(messageId)) {
218+ appendNewChatMessageUseCase.markMessageDelivered(messageId, messagesState)
219+ checkUnsentMessages(messagesState)
220+ }
221+
222+ return messagesState
223+ }
224+
199225 @VisibleForTesting
200226 fun mapNewMessage (chatMessage : ChatMessageInternal , messagesState : State ): State {
201227 check(chatMessage.chatMessage.isValid()) { " Invalid chat message passed -> ${chatMessage.chatMessage} " }
202228
203- if (messagesState.isNew(chatMessage)) {
229+ if (messagesState.isNew(chatMessage.chatMessage.id )) {
204230 appendNewChatMessageUseCase(messagesState, chatMessage)
205231 if (chatMessage.chatMessage is VisitorMessage ) {
206232 checkUnsentMessages(messagesState)
@@ -238,9 +264,9 @@ internal class ChatManager(
238264 is Action .OnMediaUpgradeTimerUpdated -> mapMediaUpgradeTimerUpdated(action.formattedValue, state)
239265 is Action .CustomCardClicked -> mapCustomCardClicked(action, state)
240266 Action .ChatRestored , Action .None -> state
241- is Action .AttachmentPreviewAdded -> mapAttachmentPreviewAdded(action.attachments , action.payload , state)
267+ is Action .AttachmentPreviewAdded -> mapAttachmentPreviewAdded(action.attachment , action.outgoingMessage , state)
242268 is Action .MessagePreviewAdded -> mapMessagePreviewAdded(action.visitorChatItem, action.payload, state)
243- is Action .OnMessageSent -> mapNewMessage( ChatMessageInternal ( action.message) , state)
269+ is Action .OnMessageSent -> mapMessageSent( action.messageId , state)
244270 is Action .OnSendMessageError -> mapSendMessageFailed(action.messageId, state)
245271 is Action .OnRetryClicked -> mapRetryClicked(action.messageId, state)
246272 is Action .OnSendMessageOperatorOffline -> mapSendMessageOperatorOffline(action.messageId, state)
@@ -289,59 +315,37 @@ internal class ChatManager(
289315 .takeIf { it != - 1 }
290316 ?.also { chatItems.removeAt(it) }
291317
292- val files = (payload.attachment as ? FilesAttachment )?.files
293-
294318 val messageIndex = chatItems.indexOfLast { it.id == payload.messageId }
295319
296320 if (messageIndex != - 1 ) {
297321 chatItems[messageIndex] = (chatItems[messageIndex] as VisitorChatItem ).copyWithError(false )
298322 }
299-
300- files?.forEach { attachment ->
301- val index = chatItems.indexOfLast { it.id == attachment.id }
302-
303- chatItems[index] = (chatItems[index] as VisitorChatItem ).copyWithError(false )
304- }
305323 }
306324
307325 @VisibleForTesting
308326 fun mapSendMessageFailed (messageId : String , state : State ): State = state.apply {
309327 val payload = messagePreviews[messageId] ? : return @apply
310328
311- val files = (payload.attachment as ? FilesAttachment )?.files.orEmpty()
312-
313329 val messageIndex = chatItems.indexOfLast { it.id == payload.messageId }
314330
315331 if (messageIndex != - 1 ) {
316332 chatItems[messageIndex] = (chatItems[messageIndex] as VisitorChatItem ).copyWithError(true )
317333 }
318334
319- files.forEach { attachment ->
320- val index = chatItems.indexOfLast { it.id == attachment.id }
321-
322- chatItems[index] = (chatItems[index] as VisitorChatItem ).copyWithError(true )
323- }
324-
325- val lastItemIndex = if (files.isNotEmpty()) {
326- chatItems.indexOfLast { (it as ? LocalAttachmentItem )?.messageId == payload.messageId }
327- } else {
328- messageIndex
329- }
330-
331- chatItems.add(lastItemIndex + 1 , TapToRetryItem (messageId = payload.messageId))
335+ chatItems.add(messageIndex + 1 , TapToRetryItem (messageId = payload.messageId))
332336 }
333337
334338 @VisibleForTesting
335- fun mapMessagePreviewAdded (visitorChatItem : VisitorChatItem , payload : SendMessagePayload , state : State ): State = state.apply {
339+ fun mapMessagePreviewAdded (visitorChatItem : VisitorChatItem , payload : OutgoingMessage , state : State ): State = state.apply {
336340 val index = indexForMessageItem(chatItems)
337341 chatItems.add(index, visitorChatItem)
338342 messagePreviews[payload.messageId] = payload
339343 }
340344
341345 @VisibleForTesting
342- fun mapAttachmentPreviewAdded (attachments : List < VisitorAttachmentItem >, payload : SendMessagePayload ? , state : State ): State = state.apply {
343- payload?. also { messagePreviews[it.messageId ] = it }
344- chatItems.addAll(attachments )
346+ fun mapAttachmentPreviewAdded (attachment : VisitorAttachmentItem , outgoingMessage : OutgoingMessage , state : State ): State = state.apply {
347+ messagePreviews[attachment.id ] = outgoingMessage
348+ chatItems.add(attachment )
345349 }
346350
347351 @VisibleForTesting
@@ -485,15 +489,15 @@ internal class ChatManager(
485489 val chatItems : MutableList <ChatItem > = mutableListOf(),
486490 val chatItemIds : MutableSet <String > = mutableSetOf(),
487491 val preEngagementChatItemIds : LinkedHashSet <String > = linkedSetOf(),
488- val messagePreviews : LinkedHashMap <String , SendMessagePayload > = LinkedHashMap (),
492+ val messagePreviews : LinkedHashMap <String , OutgoingMessage > = LinkedHashMap (),
489493 var lastMessageWithVisibleOperatorImage : OperatorChatItem ? = null ,
490494 var operatorStatusItem : OperatorStatusItem ? = null ,
491495 var mediaUpgradeTimerItem : MediaUpgradeStartedTimerItem ? = null ,
492496 var addedMessagesCount : Int = 0
493497 ) {
494498 val immutableChatItems: List <ChatItem > get() = chatItems.toList()
495499
496- fun isNew (chatMessageInternal : ChatMessageInternal ): Boolean = chatItemIds.add(chatMessageInternal.chatMessage.id )
500+ fun isNew (messageId : String ): Boolean = chatItemIds.add(messageId )
497501
498502 fun isOperatorChanged (operatorChatItem : OperatorChatItem ): Boolean = lastMessageWithVisibleOperatorImage.let {
499503 lastMessageWithVisibleOperatorImage = operatorChatItem
@@ -518,9 +522,9 @@ internal class ChatManager(
518522 data class CustomCardClicked (val customCard : CustomCardChatItem , val attachment : SingleChoiceAttachment ) : Action
519523 data object ChatRestored : Action
520524 data object None : Action
521- data class MessagePreviewAdded (val visitorChatItem : VisitorChatItem , val payload : SendMessagePayload ) : Action
522- data class AttachmentPreviewAdded (val attachments : List < VisitorAttachmentItem > , val payload : SendMessagePayload ? ) : Action
523- data class OnMessageSent (val message : VisitorMessage ) : Action
525+ data class MessagePreviewAdded (val visitorChatItem : VisitorChatItem , val payload : OutgoingMessage ) : Action
526+ data class AttachmentPreviewAdded (val attachment : VisitorAttachmentItem , val outgoingMessage : OutgoingMessage ) : Action
527+ data class OnMessageSent (val messageId : String ) : Action
524528 data class OnSendMessageError (val messageId : String ) : Action
525529 data class OnRetryClicked (val messageId : String ) : Action
526530 data class OnSendMessageOperatorOffline (val messageId : String ) : Action
0 commit comments