diff --git a/README.md b/README.md index 3ed1157..182efdf 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ From here, you are now ready to interact with the chat via the `ChatSession` obj The Amazon Connect Chat SDK for Android provides two methods to receive messages. 1. Use [ChatSession.onTranscriptUpdated](#chatsessionontranscriptupdated) - * This event will pass back the entire transcript every time the transcript is updated. This will return the transcript via a List of [TranscriptItem](#transcriptitem) + * This event will pass back the entire transcript every time the transcript is updated. This will return the transcript inside the [TranscriptData](#transcriptdata) object. 2. Use [ChatSession.onMessageReceived](#chatsessiononmessagereceived) * This event will pass back each message that is received by the WebSocket. The event handler will be passed a single [TranscriptItem](#transcriptitem). @@ -420,10 +420,10 @@ var onMessageReceived: ((TranscriptItem) -> Unit)? -------------------- #### `ChatSession.onTranscriptUpdated` -Callback for when the transcript is updated. See [TranscriptItem](#transcriptitem) +Callback for when the transcript is updated. See [TranscriptData](#transcriptdata) ``` -var onTranscriptUpdated: ((List) -> Unit)? +var onTranscriptUpdated: ((TranscriptData) -> Unit)? ``` -------------------- @@ -646,6 +646,23 @@ open class TranscriptItem( * The raw JSON format of the received WebSocket message * Type: Map of `String?` +-------- +### TranscriptData +This is the object that is passed back to the registered [ChatSession.onTranscriptUpdated](#chatsessionontranscriptupdated) event handler + +``` +data class TranscriptData( + val transcriptList: List, + val previousTranscriptNextToken: String? +) +``` +* `transcriptList` + * The current in-memory transcript list. + * Type: Array of `TranscriptItem` (See [TranscriptItem](#transcriptitem)) +* `previousTranscriptNextToken` + * This is a next token that is used as a `getTranscript` argument to retrieve older messages. This will be `null` if there are no more available messages to fetch from the top of the currently loaded transcript. + * Type: `String` + -------- ### Message (extends [TranscriptItem](#transcriptitem)) diff --git a/app/src/main/java/com/amazon/connect/chat/androidchatexample/viewmodel/ChatViewModel.kt b/app/src/main/java/com/amazon/connect/chat/androidchatexample/viewmodel/ChatViewModel.kt index bf2beca..5ab7b91 100644 --- a/app/src/main/java/com/amazon/connect/chat/androidchatexample/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/amazon/connect/chat/androidchatexample/viewmodel/ChatViewModel.kt @@ -58,6 +58,8 @@ class ChatViewModel @Inject constructor( private val _selectedFileUri = MutableLiveData(Uri.EMPTY) val selectedFileUri: MutableLiveData = _selectedFileUri + private var previousTranscriptNextToken: String? = null + // State to store chat transcript items (messages and events) var messages = mutableStateListOf() private set @@ -124,10 +126,11 @@ class ChatViewModel @Inject constructor( // Handle received websocket message if needed } - chatSession.onTranscriptUpdated = { transcriptList -> - Log.d("ChatViewModel", "Transcript onTranscriptUpdated last 3 items: ${transcriptList.takeLast(3)}") + chatSession.onTranscriptUpdated = { transcriptData -> + Log.d("ChatViewModel", "Transcript onTranscriptUpdated last 3 items: ${transcriptData.transcriptList.takeLast(3)}") + previousTranscriptNextToken = transcriptData.previousTranscriptNextToken viewModelScope.launch { - onUpdateTranscript(transcriptList) + onUpdateTranscript(transcriptData.transcriptList) } } @@ -299,7 +302,6 @@ class ChatViewModel @Inject constructor( chatSession.sendMessageReceipt(message, MessageReceiptType.MESSAGE_READ) } - // Fetch the chat transcript fun fetchTranscript(onCompletion: (Boolean) -> Unit, nextToken: String? = null) { viewModelScope.launch { @@ -307,19 +309,12 @@ class ChatViewModel @Inject constructor( ScanDirection.BACKWARD, SortKey.DESCENDING, 15, - nextToken, + previousTranscriptNextToken, null ).onSuccess { response -> Log.d("ChatViewModel", "Transcript fetched successfully") - // Check for nextToken - if (!response.nextToken.isNullOrEmpty()) { - // Call fetchTranscript again with the nextToken - fetchTranscript(onCompletion, response.nextToken) - } else { - // No more pages to fetch, call onCompletion - onCompletion(true) - } + onCompletion(true) }.onFailure { Log.e("ChatViewModel", "Error fetching transcript: ${it.message}") onCompletion(false) diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/ChatSession.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/ChatSession.kt index 9ee4c3d..1b5254c 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/ChatSession.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/ChatSession.kt @@ -11,6 +11,7 @@ import com.amazon.connect.chat.sdk.model.GlobalConfig import com.amazon.connect.chat.sdk.model.Message import com.amazon.connect.chat.sdk.model.MessageReceiptType import com.amazon.connect.chat.sdk.model.MessageStatus +import com.amazon.connect.chat.sdk.model.TranscriptData import com.amazon.connect.chat.sdk.model.TranscriptItem import com.amazon.connect.chat.sdk.model.TranscriptResponse import com.amazon.connect.chat.sdk.provider.ConnectionDetailsProvider @@ -148,7 +149,7 @@ interface ChatSession { var onConnectionBroken: (() -> Unit)? var onDeepHeartBeatFailure: (() -> Unit)? var onMessageReceived: ((TranscriptItem) -> Unit)? - var onTranscriptUpdated: ((List) -> Unit)? + var onTranscriptUpdated: ((TranscriptData) -> Unit)? var onChatEnded: (() -> Unit)? var isChatSessionActive: Boolean } @@ -161,7 +162,7 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) override var onConnectionBroken: (() -> Unit)? = null override var onDeepHeartBeatFailure: (() -> Unit)? = null override var onMessageReceived: ((TranscriptItem) -> Unit)? = null - override var onTranscriptUpdated: ((List) -> Unit)? = null + override var onTranscriptUpdated: ((TranscriptData) -> Unit)? = null override var onChatEnded: (() -> Unit)? = null override var onChatSessionStateChanged: ((Boolean) -> Unit)? = null override var isChatSessionActive: Boolean = false @@ -205,11 +206,11 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) } transcriptListCollectionJob = coroutineScope.launch { - chatService.transcriptListPublisher.collect { transcriptList -> - if (transcriptList.isNotEmpty()) { + chatService.transcriptListPublisher.collect { transcriptData -> + if (transcriptData.transcriptList.isNotEmpty()) { // Make sure it runs on main thread coroutineScope.launch { - onTranscriptUpdated?.invoke(transcriptList) + onTranscriptUpdated?.invoke(transcriptData) } } } diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/TranscriptItem.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/TranscriptItem.kt index 3a5d647..aed4e6f 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/TranscriptItem.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/TranscriptItem.kt @@ -44,3 +44,8 @@ open class TranscriptItem( } } +data class TranscriptData( + val transcriptList: List, + val previousTranscriptNextToken: String? +) + diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/repository/ChatService.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/repository/ChatService.kt index 3be2230..5bda7c5 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/repository/ChatService.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/repository/ChatService.kt @@ -5,6 +5,7 @@ package com.amazon.connect.chat.sdk.repository import android.content.Context import android.net.Uri +import android.util.Log import androidx.annotation.VisibleForTesting import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle @@ -20,6 +21,7 @@ import com.amazon.connect.chat.sdk.model.MessageMetadata import com.amazon.connect.chat.sdk.model.MessageReceiptType import com.amazon.connect.chat.sdk.model.MessageStatus import com.amazon.connect.chat.sdk.model.MetricName +import com.amazon.connect.chat.sdk.model.TranscriptData import com.amazon.connect.chat.sdk.model.TranscriptItem import com.amazon.connect.chat.sdk.model.TranscriptResponse import com.amazon.connect.chat.sdk.network.AWSClient @@ -31,6 +33,7 @@ import com.amazon.connect.chat.sdk.utils.Constants import com.amazon.connect.chat.sdk.utils.TranscriptItemUtils import com.amazon.connect.chat.sdk.utils.logger.SDKLogger import com.amazonaws.services.connectparticipant.model.GetTranscriptRequest +import com.amazonaws.services.connectparticipant.model.Item import com.amazonaws.services.connectparticipant.model.ScanDirection import com.amazonaws.services.connectparticipant.model.SortKey import com.amazonaws.services.connectparticipant.model.StartPosition @@ -39,6 +42,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch @@ -157,7 +161,7 @@ interface ChatService { val eventPublisher: SharedFlow val transcriptPublisher: SharedFlow - val transcriptListPublisher: SharedFlow> + val transcriptListPublisher: SharedFlow val chatSessionStatePublisher: SharedFlow } @@ -181,8 +185,8 @@ class ChatServiceImpl @Inject constructor( override val transcriptPublisher: SharedFlow get() = _transcriptPublisher private var transcriptCollectionJob: Job? = null - private val _transcriptListPublisher = MutableSharedFlow>() - override val transcriptListPublisher: SharedFlow> get() = _transcriptListPublisher + private val _transcriptListPublisher = MutableSharedFlow() + override val transcriptListPublisher: SharedFlow get() = _transcriptListPublisher private val _chatSessionStatePublisher = MutableSharedFlow() override val chatSessionStatePublisher: SharedFlow get() = _chatSessionStatePublisher @@ -192,11 +196,15 @@ class ChatServiceImpl @Inject constructor( private var transcriptDict = mutableMapOf() @VisibleForTesting internal var internalTranscript = mutableListOf() + @VisibleForTesting + internal var previousTranscriptNextToken: String? = null private var typingIndicatorTimer: Timer? = null private var throttleTypingEventTimer: Timer? = null private var throttleTypingEvent: Boolean = false + private var debounceTranscriptListPublisherEvent: Job? = null + // Dictionary to map attachment IDs to temporary message IDs private val attachmentIdToTempMessageId = mutableMapOf() @@ -347,7 +355,7 @@ class ChatServiceImpl @Inject constructor( // Send the updated transcript list to subscribers if items removed if (transcriptDict.size != initialCount) { coroutineScope.launch { - _transcriptListPublisher.emit(internalTranscript) + _transcriptListPublisher.emit(TranscriptData(internalTranscript, previousTranscriptNextToken)) } } } @@ -405,7 +413,11 @@ class ChatServiceImpl @Inject constructor( } } - _transcriptListPublisher.emit(internalTranscript) + debounceTranscriptListPublisherEvent?.cancel() + debounceTranscriptListPublisherEvent = coroutineScope.launch { + delay(300) + _transcriptListPublisher.emit(TranscriptData(internalTranscript, previousTranscriptNextToken)) + } } } @@ -422,7 +434,7 @@ class ChatServiceImpl @Inject constructor( internalTranscript.removeAll { it.id == oldId } // Send out updated transcript coroutineScope.launch { - _transcriptListPublisher.emit(internalTranscript) + _transcriptListPublisher.emit(TranscriptData(internalTranscript, previousTranscriptNextToken)) } } else { // Update the placeholder message's ID to the new ID @@ -553,7 +565,7 @@ class ChatServiceImpl @Inject constructor( transcriptDict.remove(messageId) // Send out updated transcript with old message removed coroutineScope.launch { - _transcriptListPublisher.emit(internalTranscript) + _transcriptListPublisher.emit(TranscriptData(internalTranscript, previousTranscriptNextToken)) } // as the next step, attempt to resend the message based on its type @@ -781,6 +793,33 @@ class ChatServiceImpl @Inject constructor( return runCatching { val response = awsClient.getTranscript(request).getOrThrow() val transcriptItems = response.transcript + + val isStartPositionDefined = request.startPosition != null && ( + request.startPosition.id != null || + request.startPosition.absoluteTime != null || + request.startPosition.mostRecent != null + ) + + if ((request.scanDirection == ScanDirection.BACKWARD.toString()) && !(isStartPositionDefined && transcriptItems.isEmpty())) { + if (internalTranscript.isEmpty() || transcriptItems.isEmpty()) { + previousTranscriptNextToken = response.nextToken + _transcriptListPublisher.emit(TranscriptData(internalTranscript, previousTranscriptNextToken)) + } else { + val oldestInternalTranscriptItem = internalTranscript.first() + val oldestTranscriptItem: Item; + if (request.sortOrder == SortKey.ASCENDING.toString()) { + oldestTranscriptItem = transcriptItems.first() + } else { + oldestTranscriptItem = transcriptItems.last() + } + val oldestInternalTranscriptItemTimeStamp = oldestInternalTranscriptItem.timeStamp + val oldestTranscriptItemTimeStamp = oldestTranscriptItem.absoluteTime + if (oldestTranscriptItemTimeStamp <= oldestInternalTranscriptItemTimeStamp) { + previousTranscriptNextToken = response.nextToken + } + } + } + // Format and process transcript items val formattedItems = transcriptItems.mapNotNull { transcriptItem -> TranscriptItemUtils.serializeTranscriptItem(transcriptItem)?.let { serializedItem -> diff --git a/chat-sdk/src/test/java/com/amazon/connect/chat/sdk/repository/ChatServiceImplTest.kt b/chat-sdk/src/test/java/com/amazon/connect/chat/sdk/repository/ChatServiceImplTest.kt index 83cefa9..60049c9 100644 --- a/chat-sdk/src/test/java/com/amazon/connect/chat/sdk/repository/ChatServiceImplTest.kt +++ b/chat-sdk/src/test/java/com/amazon/connect/chat/sdk/repository/ChatServiceImplTest.kt @@ -54,7 +54,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.setMain -import org.mockito.kotlin.any +import org.mockito.kotlin.whenever import java.util.UUID import java.net.URL @@ -87,7 +87,6 @@ class ChatServiceImplTest { private lateinit var eventSharedFlow: MutableSharedFlow private lateinit var transcriptSharedFlow: MutableSharedFlow private lateinit var chatSessionStateFlow: MutableStateFlow - private lateinit var transcriptListSharedFlow: MutableSharedFlow> private lateinit var newWsUrlFlow: MutableSharedFlow private val mockUri: Uri = Uri.parse("https://example.com/dummy.pdf") @@ -100,14 +99,13 @@ class ChatServiceImplTest { eventSharedFlow = MutableSharedFlow() transcriptSharedFlow = MutableSharedFlow() - transcriptListSharedFlow = MutableSharedFlow() chatSessionStateFlow = MutableStateFlow(false) newWsUrlFlow = MutableSharedFlow() - `when`(webSocketManager.eventPublisher).thenReturn(eventSharedFlow) - `when`(webSocketManager.transcriptPublisher).thenReturn(transcriptSharedFlow) - `when`(webSocketManager.requestNewWsUrlFlow).thenReturn(newWsUrlFlow) - `when`(connectionDetailsProvider.chatSessionState).thenReturn(chatSessionStateFlow) + whenever(webSocketManager.eventPublisher).thenReturn(eventSharedFlow) + whenever(webSocketManager.transcriptPublisher).thenReturn(transcriptSharedFlow) + whenever(webSocketManager.requestNewWsUrlFlow).thenReturn(newWsUrlFlow) + whenever(connectionDetailsProvider.chatSessionState).thenReturn(chatSessionStateFlow) chatService = ChatServiceImpl( context, @@ -532,6 +530,81 @@ class ChatServiceImplTest { return item } + @Test + fun test_getTranscript_previousTranscriptNextToken() = runTest { + val chatServiceInstance = chatService as ChatServiceImpl + + val mockConnectionDetails = createMockConnectionDetails("valid_token") + `when`(connectionDetailsProvider.getConnectionDetails()).thenReturn(mockConnectionDetails) + val chatDetails = ChatDetails(participantToken = "token") + chatServiceInstance.createChatSession(chatDetails) + advanceUntilIdle() + + // Create a mock GetTranscriptResult and configure it to return expected values + val mockGetTranscriptResult = mock() + `when`(mockGetTranscriptResult.transcript).thenReturn(listOf()) + `when`(mockGetTranscriptResult.nextToken).thenReturn("nextToken1") + `when`(mockGetTranscriptResult.initialContactId).thenReturn("") + `when`(awsClient.getTranscript(anyOrNull())).thenReturn(Result.success(mockGetTranscriptResult)) + + chatServiceInstance.getTranscript(ScanDirection.BACKWARD, SortKey.ASCENDING, 10, null, null) + advanceUntilIdle() + + // Expect previousTranscriptNextToken to be set when internal transcript is empty. + assertEquals(chatServiceInstance.previousTranscriptNextToken, "nextToken1") + + // Add items to the internal transcript + val transcriptItem1 = Message(id = "1", timeStamp = "2024-01-01T00:00:00Z", participant = "user", contentType = "text/plain", text = "Hello") + val transcriptItem2 = Message(id = "2", timeStamp = "2025-01-01T00:01:00Z", participant = "agent", contentType = "text/plain", text = "Hi") + transcriptSharedFlow.emit(transcriptItem1) + transcriptSharedFlow.emit(transcriptItem2) + advanceUntilIdle() + + // Expect previousTranscriptNextToken to persist when items are added + assertEquals(chatServiceInstance.previousTranscriptNextToken, "nextToken1") + + // Expect previousTranscriptNextToken to be set when getTranscript returns an empty list. + `when`(mockGetTranscriptResult.transcript).thenReturn(listOf()) + `when`(mockGetTranscriptResult.nextToken).thenReturn("nextToken2") + `when`(awsClient.getTranscript(anyOrNull())).thenReturn(Result.success(mockGetTranscriptResult)) + + chatServiceInstance.getTranscript(ScanDirection.BACKWARD, SortKey.ASCENDING, 10, null, null) + advanceUntilIdle() + + assertEquals(chatServiceInstance.previousTranscriptNextToken, "nextToken2") + + // Expect previousTranscriptNextToken to be set when getTranscript returns an older message. + val item1 = createMockItem("1", "2023-01-01T00:00:00Z") + `when`(mockGetTranscriptResult.transcript).thenReturn(listOf(item1)) + `when`(mockGetTranscriptResult.nextToken).thenReturn("nextToken3") + + chatServiceInstance.getTranscript(ScanDirection.BACKWARD, SortKey.ASCENDING, 10, null, null) + advanceUntilIdle() + + assertEquals(chatServiceInstance.previousTranscriptNextToken, "nextToken3") + + // Expect previousTranscriptNextToken to be the same when getTranscript returns a newer message. + val item2 = createMockItem("2", "2025-01-01T00:00:00Z") + `when`(mockGetTranscriptResult.transcript).thenReturn(listOf(item2)) + `when`(mockGetTranscriptResult.nextToken).thenReturn("nextToken4") + + chatServiceInstance.getTranscript(ScanDirection.BACKWARD, SortKey.ASCENDING, 10, null, null) + advanceUntilIdle() + + assertEquals(chatServiceInstance.previousTranscriptNextToken, "nextToken3") + + // Expect previousTranscriptNextToken to be the same when receiving an empty array using startPosition. + `when`(mockGetTranscriptResult.transcript).thenReturn(listOf()) + `when`(mockGetTranscriptResult.nextToken).thenReturn("nextToken5") + val mockStartPosition = StartPosition() + mockStartPosition.id = "1234" + + chatServiceInstance.getTranscript(ScanDirection.BACKWARD, SortKey.ASCENDING, 10, null, mockStartPosition) + advanceUntilIdle() + + assertEquals(chatServiceInstance.previousTranscriptNextToken, "nextToken3") + } + @Test fun test_fetchReconnectedTranscript_success() = runTest { val chatDetails = ChatDetails(participantToken = "token") @@ -591,58 +664,92 @@ class ChatServiceImplTest { @Test fun test_eventPublisher_emitsCorrectEvent() = runTest { - val chatEvent = ChatEvent.ConnectionEstablished + var assertCalled = false + val chatDetails = ChatDetails(participantToken = "token") + chatService.createChatSession(chatDetails) + advanceUntilIdle() + + val chatEvent = ChatEvent.ChatEnded // Launch the flow collection within the test's coroutine scope val job = chatService.eventPublisher .onEach { event -> assertEquals(chatEvent, event) + assertCalled = true } .launchIn(this) // Emit the event eventSharedFlow.emit(chatEvent) + advanceUntilIdle() // Cancel the job after testing to ensure the coroutine completes job.cancel() + + if (!assertCalled) { + fail("chatService.eventPublisher.onEach was not triggered") + } } @Test fun test_transcriptPublisher_emitsCorrectTranscriptItem() = runTest { + var assertCalled = false + val chatDetails = ChatDetails(participantToken = "token") + chatService.createChatSession(chatDetails) + advanceUntilIdle() + val transcriptItem = Message(id = "1", timeStamp = "mockedTimestamp", participant = "user", contentType = "text/plain", text = "Hello") val job = chatService.transcriptPublisher .onEach { item -> assertEquals(transcriptItem, item) + assertCalled = true } .launchIn(this) // Emit the transcript item transcriptSharedFlow.emit(transcriptItem) - + advanceUntilIdle() // Cancel the job after testing to ensure the coroutine completes job.cancel() + + if (!assertCalled) { + fail("chatService.transcriptPublisher.onEach was not triggered") + } } @Test fun test_transcriptListPublisher_emitsTranscriptList() = runTest { + var assertCalled = false + val chatDetails = ChatDetails(participantToken = "token") + chatService.createChatSession(chatDetails) + advanceUntilIdle() + val transcriptItem1 = Message(id = "1", timeStamp = "2024-01-01T00:00:00Z", participant = "user", contentType = "text/plain", text = "Hello") val transcriptItem2 = Message(id = "2", timeStamp = "2024-01-01T00:01:00Z", participant = "agent", contentType = "text/plain", text = "Hi") - val transcriptList = listOf(transcriptItem1, transcriptItem2) // Launch the flow collection within the test's coroutine scope val job = chatService.transcriptListPublisher - .onEach { items -> - assertEquals(transcriptList, items) + .onEach { transcriptData -> + assertEquals(transcriptData.transcriptList.size, 2) + assertEquals(transcriptData.transcriptList[0], transcriptItem1) + assertEquals(transcriptData.transcriptList[1], transcriptItem2) + assertCalled = true } .launchIn(this) // Emit the transcript list - transcriptListSharedFlow.emit(transcriptList) + transcriptSharedFlow.emit(transcriptItem1) + transcriptSharedFlow.emit(transcriptItem2) + advanceUntilIdle() // Cancel the job after testing to ensure the coroutine completes job.cancel() + + if (!assertCalled) { + fail("chatService.transcriptPublisher.onEach was not triggered") + } } @Test diff --git a/chat-sdk/version.properties b/chat-sdk/version.properties index c232642..c978484 100644 --- a/chat-sdk/version.properties +++ b/chat-sdk/version.properties @@ -1,3 +1,3 @@ -sdkVersion=1.0.10 +sdkVersion=2.0.0 groupId=software.aws.connect artifactId=amazon-connect-chat-android