Skip to content

Commit 5eb4d4f

Browse files
committed
Migrate widgets chat send to the new unified Core API
Core removed SendMessagePayload and reworked Chat/SecureConversations sending into per-type methods with onSuccess/onFailure callbacks. Replace the payload with a widgets-internal OutgoingMessage (Text/SingleChoice/File) and send text and each file attachment as separate messages, reconciling optimistic previews with the echoed message from the CHAT_MESSAGE stream by client message id (the file id for attachments). Send callbacks no longer arrive on the main thread, so SecureConversationsRepository marshals them back to it and ChatManager serializes all chat-state mapping on the main thread to keep the send-success and message-echo paths from processing the same message twice. Unit tests for the send flow updated and added. MOB-5376
1 parent da75325 commit 5eb4d4f

22 files changed

Lines changed: 1188 additions & 468 deletions

widgetssdk/src/main/java/com/glia/widgets/chat/ChatManager.kt

Lines changed: 48 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@ package com.glia.widgets.chat
22

33
import android.text.format.DateUtils
44
import androidx.annotation.VisibleForTesting
5-
import com.glia.androidsdk.chat.FilesAttachment
65
import com.glia.androidsdk.chat.OperatorMessage
7-
import com.glia.androidsdk.chat.SendMessagePayload
86
import com.glia.androidsdk.chat.SingleChoiceAttachment
97
import com.glia.androidsdk.chat.SystemMessage
108
import com.glia.androidsdk.chat.VisitorMessage
@@ -23,12 +21,12 @@ import com.glia.widgets.chat.model.ChatItem
2321
import com.glia.widgets.chat.model.CustomCardChatItem
2422
import com.glia.widgets.chat.model.GvaButton
2523
import com.glia.widgets.chat.model.GvaQuickReplies
26-
import com.glia.widgets.chat.model.LocalAttachmentItem
2724
import com.glia.widgets.chat.model.MediaUpgradeStartedTimerItem
2825
import com.glia.widgets.chat.model.NewMessagesDividerItem
2926
import com.glia.widgets.chat.model.OperatorChatItem
3027
import com.glia.widgets.chat.model.OperatorMessageItem
3128
import com.glia.widgets.chat.model.OperatorStatusItem
29+
import com.glia.widgets.chat.model.OutgoingMessage
3230
import com.glia.widgets.chat.model.RemoteAttachmentItem
3331
import com.glia.widgets.chat.model.TapToRetryItem
3432
import com.glia.widgets.chat.model.VisitorAttachmentItem
@@ -47,6 +45,7 @@ import io.reactivex.rxjava3.core.Flowable
4745
import io.reactivex.rxjava3.disposables.CompositeDisposable
4846
import io.reactivex.rxjava3.disposables.Disposable
4947
import io.reactivex.rxjava3.processors.BehaviorProcessor
48+
import io.reactivex.rxjava3.processors.FlowableProcessor
5049
import io.reactivex.rxjava3.schedulers.Schedulers
5150

5251
internal 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

widgetssdk/src/main/java/com/glia/widgets/chat/controller/ChatController.kt

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@ import com.glia.androidsdk.Engagement
66
import com.glia.androidsdk.GliaException
77
import com.glia.androidsdk.Operator
88
import com.glia.androidsdk.chat.AttachmentFile
9-
import com.glia.androidsdk.chat.SendMessagePayload
109
import com.glia.androidsdk.chat.SingleChoiceAttachment
1110
import com.glia.androidsdk.chat.SingleChoiceOption
12-
import com.glia.androidsdk.chat.VisitorMessage
1311
import com.glia.androidsdk.comms.MediaState
1412
import com.glia.androidsdk.engagement.EngagementFile
1513
import com.glia.telemetry_lib.ButtonNames
@@ -39,6 +37,7 @@ import com.glia.widgets.chat.model.CustomCardChatItem
3937
import com.glia.widgets.chat.model.Gva
4038
import com.glia.widgets.chat.model.GvaButton
4139
import com.glia.widgets.chat.model.OperatorMessageItem
40+
import com.glia.widgets.chat.model.OutgoingMessage
4241
import com.glia.widgets.chat.model.VisitorAttachmentItem
4342
import com.glia.widgets.chat.model.VisitorChatItem
4443
import com.glia.widgets.di.Dependencies
@@ -66,7 +65,6 @@ import com.glia.widgets.helper.TimeCounter.FormattedTimerStatusListener
6665
import com.glia.widgets.helper.exists
6766
import com.glia.widgets.helper.formattedName
6867
import com.glia.widgets.helper.imageUrl
69-
import com.glia.widgets.helper.isValid
7068
import com.glia.widgets.internal.dialog.DialogContract
7169
import com.glia.widgets.internal.dialog.domain.ConfirmationDialogLinksUseCase
7270
import com.glia.widgets.internal.dialog.domain.IsShowOverlayPermissionRequestDialogUseCase
@@ -161,9 +159,9 @@ internal class ChatController(
161159
private var allowedMediaTypes: List<String> = listOf(Constants.MIME_TYPE_IMAGES)
162160

163161
private val sendMessageCallback: GliaSendMessageUseCase.Listener = object : GliaSendMessageUseCase.Listener {
164-
override fun messageSent(message: VisitorMessage?) {
165-
Logger.d(TAG, "messageSent: $message, id: ${message?.id}")
166-
onMessageSent(message)
162+
override fun messageSent(messageId: String) {
163+
Logger.d(TAG, "message sent id=$messageId")
164+
onMessageSent(messageId)
167165
}
168166

169167
override fun onMessageValidated() {
@@ -176,13 +174,13 @@ internal class ChatController(
176174
onSendMessageOperatorOffline()
177175
}
178176

179-
override fun onMessagePrepared(visitorChatItem: VisitorChatItem, payload: SendMessagePayload) {
177+
override fun onMessagePrepared(visitorChatItem: VisitorChatItem, payload: OutgoingMessage) {
180178
addMessagePreview(visitorChatItem, payload)
181179
scrollChatToBottom()
182180
}
183181

184-
override fun onAttachmentsPrepared(items: List<VisitorAttachmentItem>, payload: SendMessagePayload?) {
185-
addAttachmentPreview(items, payload)
182+
override fun onAttachmentPrepared(attachment: VisitorAttachmentItem, outgoingMessage: OutgoingMessage) {
183+
chatManager.onChatAction(ChatManager.Action.AttachmentPreviewAdded(attachment, outgoingMessage))
186184
scrollChatToBottom()
187185
}
188186

@@ -441,20 +439,15 @@ internal class ChatController(
441439
}
442440
}
443441

444-
private fun onMessageSent(message: VisitorMessage?) {
445-
message?.takeIf { it.isValid() }?.also { chatManager.onChatAction(ChatManager.Action.OnMessageSent(it)) }
442+
private fun onMessageSent(messageId: String) {
443+
chatManager.onChatAction(ChatManager.Action.OnMessageSent(messageId))
446444
}
447445

448-
private fun addMessagePreview(visitorChatItem: VisitorChatItem, payload: SendMessagePayload) {
446+
private fun addMessagePreview(visitorChatItem: VisitorChatItem, payload: OutgoingMessage) {
449447
chatManager.onChatAction(ChatManager.Action.MessagePreviewAdded(visitorChatItem, payload))
450448
scrollChatToBottom()
451449
}
452450

453-
private fun addAttachmentPreview(items: List<VisitorAttachmentItem>, payload: SendMessagePayload?) {
454-
chatManager.onChatAction(ChatManager.Action.AttachmentPreviewAdded(items, payload))
455-
scrollChatToBottom()
456-
}
457-
458451
private fun onOperatorTyping(isOperatorTyping: Boolean) {
459452
emitViewState { chatState.setIsOperatorTyping(isOperatorTyping) }
460453
}

widgetssdk/src/main/java/com/glia/widgets/chat/data/GliaChatRepository.kt

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ package com.glia.widgets.chat.data
22

33
import com.glia.androidsdk.Glia
44
import com.glia.androidsdk.GliaException
5-
import com.glia.androidsdk.RequestCallback
5+
import com.glia.androidsdk.chat.Chat
66
import com.glia.androidsdk.chat.ChatMessage
7-
import com.glia.androidsdk.chat.SendMessagePayload
8-
import com.glia.androidsdk.chat.VisitorMessage
9-
import com.glia.widgets.chat.domain.GliaSendMessageUseCase.Listener
7+
import com.glia.androidsdk.chat.SingleChoiceAttachment
108
import com.glia.widgets.di.GliaCore
119
import java.util.function.Consumer
1210

@@ -36,31 +34,27 @@ internal class GliaChatRepository(private val gliaCore: GliaCore) {
3634

3735
fun sendMessagePreview(message: String?) {
3836
message ?: return
39-
gliaCore.currentEngagement.ifPresent { it.chat.sendMessagePreview(message) }
37+
withChat { it.sendMessagePreview(message) }
4038
}
4139

42-
fun sendMessage(payload: SendMessagePayload, callback: RequestCallback<VisitorMessage?>) {
43-
gliaCore.currentEngagement.ifPresent { engagement ->
44-
engagement.chat.sendMessage(payload) { visitorMessage, ex -> callback.onResult(visitorMessage, ex) }
45-
}
40+
fun sendMessage(content: String, messageId: String, onSuccess: () -> Unit, onFailure: (GliaException) -> Unit) {
41+
withChat { it.sendMessage(content, messageId, onSuccess, onFailure) }
4642
}
4743

48-
fun sendMessage(payload: SendMessagePayload, listener: Listener) {
49-
sendMessage(payload) { visitorMessage, ex -> onMessageReceived(visitorMessage, ex, listener, payload.messageId) }
44+
fun sendSingleChoiceAttachment(
45+
singleChoiceAttachment: SingleChoiceAttachment,
46+
messageId: String,
47+
onSuccess: () -> Unit,
48+
onFailure: (GliaException) -> Unit
49+
) {
50+
withChat { it.sendSingleChoiceAttachment(singleChoiceAttachment, messageId, onSuccess, onFailure) }
5051
}
5152

52-
private fun onMessageReceived(
53-
visitorMessage: VisitorMessage?,
54-
ex: GliaException?,
55-
listener: Listener?,
56-
messageId: String
57-
) {
58-
if (listener != null) {
59-
if (ex != null) {
60-
listener.error(ex, messageId)
61-
} else {
62-
listener.messageSent(visitorMessage)
63-
}
64-
}
53+
fun sendFileAttachment(fileId: String, onSuccess: () -> Unit, onFailure: (GliaException) -> Unit) {
54+
withChat { it.sendFileAttachment(fileId, onSuccess, onFailure) }
55+
}
56+
57+
private fun withChat(block: (Chat) -> Unit) {
58+
gliaCore.currentEngagement.ifPresent { block(it.chat) }
6559
}
6660
}

0 commit comments

Comments
 (0)