diff --git a/app/src/main/java/com/amazon/connect/chat/androidchatexample/MainActivity.kt b/app/src/main/java/com/amazon/connect/chat/androidchatexample/MainActivity.kt index 08bb99d..aaa2eb2 100644 --- a/app/src/main/java/com/amazon/connect/chat/androidchatexample/MainActivity.kt +++ b/app/src/main/java/com/amazon/connect/chat/androidchatexample/MainActivity.kt @@ -125,7 +125,6 @@ fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) { dismissButton = { TextButton(onClick = { showRestoreDialog = false - viewModel.clearContactId() // Clear contactId viewModel.clearParticipantToken() viewModel.initiateChat() // Start new chat }) { Text("Start new") } @@ -158,7 +157,7 @@ fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) { if (!showCustomSheet) { ExtendedFloatingActionButton( text = { - if (isChatActive.value) { + if (isChatActive.value == false) { Text("Start Chat") } else { Text("Resume Chat") @@ -187,7 +186,7 @@ fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) { } } - ContactIdAndTokenSection(viewModel) + ParticipantTokenSection(viewModel) AnimatedVisibility( visible = showCustomSheet, @@ -307,23 +306,14 @@ fun ChatMessage(transcriptItem: TranscriptItem) { } @Composable -fun ContactIdAndTokenSection(viewModel: ChatViewModel) { - val contactId by viewModel.liveContactId.observeAsState() +fun ParticipantTokenSection(viewModel: ChatViewModel) { val participantToken by viewModel.liveParticipantToken.observeAsState() Column { - Text(text = "Contact ID: ${if (contactId != null) "Available" else "Not available"}", color = if (contactId != null) Color.Blue else Color.Red) - Button(onClick = viewModel::clearContactId) { - Text("Clear Contact ID") - } - Spacer(modifier = Modifier.height(8.dp)) Text(text = "Participant Token: ${if (participantToken != null) "Available" else "Not available"}", color = if (participantToken != null) Color.Blue else Color.Red) Button(onClick = viewModel::clearParticipantToken) { Text("Clear Participant Token") } - Button(onClick = viewModel::endChat) { - Text(text = "Disconnect") - } } } diff --git a/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatRequest.kt b/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatRequest.kt index 002deb9..152cc28 100644 --- a/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatRequest.kt +++ b/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatRequest.kt @@ -5,7 +5,6 @@ import com.google.gson.annotations.SerializedName data class StartChatRequest( @SerializedName("InstanceId") val connectInstanceId: String, @SerializedName("ContactFlowId") val contactFlowId: String, - @SerializedName("PersistentChat") val persistentChat: PersistentChat? = null, @SerializedName("ParticipantDetails") val participantDetails: ParticipantDetails, @SerializedName("SupportedMessagingContentTypes") val supportedMessagingContentTypes: List = listOf("text/plain", "text/markdown") ) @@ -14,7 +13,3 @@ data class ParticipantDetails( @SerializedName("DisplayName") val displayName: String ) -data class PersistentChat( - @SerializedName("SourceContactId") val sourceContactId: String, - @SerializedName("RehydrationType") val rehydrationType: String -) \ No newline at end of file diff --git a/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CommonUtils.kt b/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CommonUtils.kt index 34db381..ea25a0b 100644 --- a/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CommonUtils.kt +++ b/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CommonUtils.kt @@ -5,9 +5,29 @@ import com.amazon.connect.chat.sdk.model.Message import com.amazon.connect.chat.sdk.model.MessageDirection import com.amazon.connect.chat.sdk.model.TranscriptItem import com.amazon.connect.chat.sdk.model.ContentType +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone object CommonUtils { + fun formatTime(timeStamp: String): String { + val utcFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + val date = utcFormatter.parse(timeStamp) + return if (date != null) { + val localFormatter = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { + timeZone = TimeZone.getDefault() + } + localFormatter.format(date) + } else { + timeStamp + } + } + + fun getMessageDirection(transcriptItem: TranscriptItem) { when (transcriptItem) { is Message -> { 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 3e9da2a..abd7399 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 @@ -5,30 +5,29 @@ import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.amazon.connect.chat.androidchatexample.models.StartChatResponse -import com.amazon.connect.chat.androidchatexample.network.Resource -import com.amazon.connect.chat.androidchatexample.repository.ChatRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import androidx.lifecycle.viewModelScope -import com.amazonaws.handlers.AsyncHandler -import com.amazonaws.services.connectparticipant.model.CreateParticipantConnectionRequest -import com.amazonaws.services.connectparticipant.model.CreateParticipantConnectionResult import com.amazon.connect.chat.androidchatexample.Config -import com.amazon.connect.chat.sdk.model.Message import com.amazon.connect.chat.androidchatexample.models.ParticipantDetails -import com.amazon.connect.chat.androidchatexample.models.PersistentChat import com.amazon.connect.chat.androidchatexample.models.StartChatRequest +import com.amazon.connect.chat.androidchatexample.models.StartChatResponse +import com.amazon.connect.chat.androidchatexample.network.Resource +import com.amazon.connect.chat.androidchatexample.repository.ChatRepository import com.amazon.connect.chat.androidchatexample.utils.CommonUtils -import com.amazon.connect.chat.sdk.network.WebSocketManager -import com.amazon.connect.chat.sdk.utils.CommonUtils.Companion.parseErrorMessage -import com.amazon.connect.chat.sdk.model.ContentType import com.amazon.connect.chat.sdk.ChatSession import com.amazon.connect.chat.sdk.model.ChatDetails +import com.amazon.connect.chat.sdk.model.ContentType import com.amazon.connect.chat.sdk.model.Event import com.amazon.connect.chat.sdk.model.GlobalConfig +import com.amazon.connect.chat.sdk.model.Message import com.amazon.connect.chat.sdk.model.TranscriptItem +import com.amazon.connect.chat.sdk.network.WebSocketManager +import com.amazon.connect.chat.sdk.utils.CommonUtils.Companion.parseErrorMessage +import com.amazonaws.handlers.AsyncHandler +import com.amazonaws.services.connectparticipant.model.CreateParticipantConnectionRequest +import com.amazonaws.services.connectparticipant.model.CreateParticipantConnectionResult +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import javax.inject.Inject @HiltViewModel class ChatViewModel @Inject constructor( @@ -51,21 +50,9 @@ class ChatViewModel @Inject constructor( private val _errorMessage = MutableLiveData() val errorMessage: LiveData = _errorMessage - // LiveData for actual string values, updates will reflect in the UI - private val _liveContactId = MutableLiveData(sharedPreferences.getString("contactID", null)) - val liveContactId: LiveData = _liveContactId - private val _liveParticipantToken = MutableLiveData(sharedPreferences.getString("participantToken", null)) val liveParticipantToken: LiveData = _liveParticipantToken - // Setters that update LiveData, which in turn update the UI - private var contactId: String? - get() = liveContactId.value - set(value) { -// sharedPreferences.edit().putString("contactID", value).apply() - _liveContactId.value = value - } - private var participantToken: String? get() = liveParticipantToken.value set(value) { @@ -73,27 +60,24 @@ class ChatViewModel @Inject constructor( _liveParticipantToken.value = value // Reflect the new value in LiveData } - fun clearContactId() { - sharedPreferences.edit().remove("contactID").apply() - _liveContactId.value = null - } - fun clearParticipantToken() { sharedPreferences.edit().remove("participantToken").apply() _liveParticipantToken.value = null } init { - configureChatSession() + viewModelScope.launch { + configureChatSession() + } } - private fun configureChatSession() { + private suspend fun configureChatSession() { val globalConfig = GlobalConfig(region = chatConfiguration.region) chatSession.configure(globalConfig) setupChatHandlers(chatSession) } - private fun setupChatHandlers(chatSession: ChatSession) { + private suspend fun setupChatHandlers(chatSession: ChatSession) { chatSession.onConnectionEstablished = { Log.d("ChatViewModel", "Connection established.") _isChatActive.value = true @@ -102,7 +86,14 @@ class ChatViewModel @Inject constructor( chatSession.onMessageReceived = { transcriptItem -> // Handle received message Log.d("ChatViewModel", "Received transcript item: $transcriptItem") - this.onMessageReceived(transcriptItem) +// this.onMessageReceived(transcriptItem) + } + + chatSession.onTranscriptUpdated = { transcriptList -> + Log.d("ChatViewModel", "Transcript onTranscriptUpdated: $transcriptList") + viewModelScope.launch { + onUpdateTranscript(transcriptList) + } } chatSession.onChatEnded = { @@ -118,6 +109,11 @@ class ChatViewModel @Inject constructor( Log.d("ChatViewModel", "Connection re-established.") _isChatActive.value = true } + + chatSession.onChatSessionStateChanged = { + Log.d("ChatViewModel", "Chat session state changed: $it") + _isChatActive.value = it + } } fun initiateChat() { @@ -129,29 +125,24 @@ class ChatViewModel @Inject constructor( val chatDetails = ChatDetails(participantToken = it) createParticipantConnection(chatDetails) } - } else if (contactId != null) { - startChat(contactId) } else { - startChat(null) // Start a fresh chat if no tokens are present + startChat() // Start a fresh chat if no tokens are present } } } - private fun startChat(sourceContactId: String?) { + private fun startChat() { viewModelScope.launch { _isLoading.value = true val participantDetails = ParticipantDetails(displayName = chatConfiguration.customerName) - val persistentChat: PersistentChat? = sourceContactId?.let { PersistentChat(it, "ENTIRE_PAST_SESSION") } val request = StartChatRequest( connectInstanceId = chatConfiguration.connectInstanceId, contactFlowId = chatConfiguration.contactFlowId, - participantDetails = participantDetails, - persistentChat = persistentChat + participantDetails = participantDetails ) when (val response = chatRepository.startChat(startChatRequest = request)) { is Resource.Success -> { response.data?.data?.startChatResult?.let { result -> - this@ChatViewModel.contactId = result.contactId this@ChatViewModel.participantToken = result.participantToken handleStartChatResponse(result) } ?: run { @@ -161,7 +152,6 @@ class ChatViewModel @Inject constructor( is Resource.Error -> { _errorMessage.value = response.message _isLoading.value = false - clearContactId() } is Resource.Loading -> _isLoading.value = true @@ -197,7 +187,7 @@ class ChatViewModel @Inject constructor( private fun createParticipantConnection1(chatDetails: ChatDetails?) { - val pToken: String = if (chatDetails?.contactId == null) participantToken.toString() else chatDetails?.participantToken.toString() + val pToken: String = chatDetails?.participantToken.toString() viewModelScope.launch { _isLoading.value = true // Start loading chatRepository.createParticipantConnection( @@ -261,6 +251,18 @@ class ChatViewModel @Inject constructor( } } + private fun onUpdateTranscript(transcriptList: List) { + val updatedMessages = transcriptList.map { transcriptItem -> + if (transcriptItem is Event) { + CommonUtils.customizeEvent(transcriptItem) + } + CommonUtils.getMessageDirection(transcriptItem) + transcriptItem + } + _messages.value = updatedMessages + Log.d("ChatViewModel", "Transcript updated: ${_messages.value}") + } + private fun onMessageReceived(transcriptItem: TranscriptItem) { viewModelScope.launch { // Log the current state before the update diff --git a/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ChatComponents.kt b/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ChatComponents.kt index 010ed3a..a6f6570 100644 --- a/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ChatComponents.kt +++ b/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ChatComponents.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.amazon.connect.chat.androidchatexample.utils.CommonUtils import com.amazon.connect.chat.sdk.model.Event import com.amazon.connect.chat.sdk.model.ListPickerContent import com.amazon.connect.chat.sdk.model.Message @@ -83,7 +84,7 @@ fun SenderChatBubble(message: Message) { ) message.timeStamp?.let { Text( - text = it, + text = CommonUtils.formatTime(it), style = MaterialTheme.typography.bodySmall, color = Color(0xFFB0BEC5), modifier = Modifier.align(Alignment.End) @@ -130,7 +131,7 @@ fun ReceiverChatBubble(message: Message) { ) message.timeStamp?.let { Text( - text = it, + text = CommonUtils.formatTime(it), style = MaterialTheme.typography.bodySmall, color = Color.White, modifier = Modifier.align(Alignment.End).alpha(0.7f) diff --git a/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ListPickerContentView.kt b/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ListPickerContentView.kt index 292e2d2..5ab45e4 100644 --- a/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ListPickerContentView.kt +++ b/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ListPickerContentView.kt @@ -36,6 +36,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import coil.request.ImageRequest import com.amazon.connect.chat.androidchatexample.R +import com.amazon.connect.chat.androidchatexample.utils.CommonUtils import com.amazon.connect.chat.sdk.model.ListPickerContent import com.amazon.connect.chat.sdk.model.ListPickerElement import com.amazon.connect.chat.sdk.model.Message @@ -115,7 +116,7 @@ fun ListPickerContentView( ) message.timeStamp?.let { Text( - text = it, + text = CommonUtils.formatTime(it), style = MaterialTheme.typography.bodySmall, color = Color.White, modifier = Modifier.align(Alignment.End).alpha(0.7f) diff --git a/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/QuickReplyContentView.kt b/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/QuickReplyContentView.kt index 9ddaefd..2b4d074 100644 --- a/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/QuickReplyContentView.kt +++ b/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/QuickReplyContentView.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.amazon.connect.chat.androidchatexample.utils.CommonUtils import com.amazon.connect.chat.sdk.model.Message import com.amazon.connect.chat.sdk.model.QuickReplyContent import com.amazon.connect.chat.androidchatexample.viewmodel.ChatViewModel @@ -56,7 +57,7 @@ fun QuickReplyContentView(message: Message, messageContent: QuickReplyContent) { ) message.timeStamp?.let { Text( - text = it, + text = CommonUtils.formatTime(it), style = MaterialTheme.typography.bodySmall, color = Color.White, modifier = Modifier.align(Alignment.End).alpha(0.7f) 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 4be8ef3..5031f19 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 @@ -28,10 +28,17 @@ interface ChatSession { */ suspend fun disconnect(): Result + /** + * Checks if a chat session is currently active. + * @return True if a chat session is active, false otherwise. + */ + var onChatSessionStateChanged: ((Boolean) -> Unit)? + var onConnectionEstablished: (() -> Unit)? var onConnectionReEstablished: (() -> Unit)? var onConnectionBroken: (() -> Unit)? var onMessageReceived: ((TranscriptItem) -> Unit)? + var onTranscriptUpdated: ((List) -> Unit)? var onChatEnded: (() -> Unit)? } @@ -42,19 +49,19 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) override var onConnectionReEstablished: (() -> Unit)? = null override var onConnectionBroken: (() -> Unit)? = null override var onMessageReceived: ((TranscriptItem) -> Unit)? = null + override var onTranscriptUpdated: ((List) -> Unit)? = null override var onChatEnded: (() -> Unit)? = null + override var onChatSessionStateChanged: ((Boolean) -> Unit)? = null private val coroutineScope = CoroutineScope(Dispatchers.Main + Job()) private var eventCollectionJob: Job? = null private var transcriptCollectionJob: Job? = null + private var transcriptListCollectionJob: Job? = null + private var chatSessionStateCollectionJob: Job? = null - init { - setupEventSubscriptions() - } private fun setupEventSubscriptions() { // Cancel any existing subscriptions before setting up new ones - eventCollectionJob?.cancel() - transcriptCollectionJob?.cancel() + cleanup() // Set up new subscriptions eventCollectionJob = coroutineScope.launch { @@ -70,7 +77,27 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) transcriptCollectionJob = coroutineScope.launch { chatService.transcriptPublisher.collect { transcriptItem -> - onMessageReceived?.invoke(transcriptItem) + // Make sure it runs on main thread + coroutineScope.launch { + onMessageReceived?.invoke(transcriptItem) + } + } + } + + transcriptListCollectionJob = coroutineScope.launch { + chatService.transcriptListPublisher.collect { transcriptList -> + if (transcriptList.isNotEmpty()) { + // Make sure it runs on main thread + coroutineScope.launch { + onTranscriptUpdated?.invoke(transcriptList) + } + } + } + } + + chatSessionStateCollectionJob = coroutineScope.launch { + chatService.chatSessionStatePublisher.collect { isActive -> + onChatSessionStateChanged?.invoke(isActive) } } } @@ -80,6 +107,8 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) } override suspend fun connect(chatDetails: ChatDetails): Result { + // Establish subscriptions whenever a new chat session is initiated + setupEventSubscriptions() return withContext(Dispatchers.IO) { runCatching { chatService.createChatSession(chatDetails) @@ -107,5 +136,12 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) // Cancel flow collection jobs when disconnecting or cleaning up eventCollectionJob?.cancel() transcriptCollectionJob?.cancel() + transcriptListCollectionJob?.cancel() + chatSessionStateCollectionJob?.cancel() + + eventCollectionJob = null + transcriptCollectionJob = null + transcriptListCollectionJob = null + chatSessionStateCollectionJob = null } } \ No newline at end of file diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/WebSocketManager.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/WebSocketManager.kt index 96e212d..8ba2bea 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/WebSocketManager.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/WebSocketManager.kt @@ -131,20 +131,20 @@ class WebSocketManagerImpl @Inject constructor( // --- Initialization and Connection Management --- override suspend fun connect(wsUrl: String, isReconnectFlow: Boolean) { - closeWebSocket() + closeWebSocket("Connecting...") val request = Request.Builder().url(wsUrl).build() webSocket = client.newWebSocket(request, createWebSocketListener(isReconnectFlow)) } - private fun closeWebSocket() { + private fun closeWebSocket(reason: String? = null) { CoroutineScope(Dispatchers.IO).launch { resetHeartbeatManagers() - webSocket?.close(1000, null) + webSocket?.close(1000, reason) } } override suspend fun disconnect() { - closeWebSocket() + closeWebSocket("Disconnecting...") } // --- WebSocket Listener --- @@ -408,7 +408,7 @@ class WebSocketManagerImpl @Inject constructor( } private suspend fun handleChatEnded(innerJson: JSONObject): TranscriptItem { - closeWebSocket(); + closeWebSocket("Chat Ended"); isChatActive = false; this._eventPublisher.emit(ChatEvent.ChatEnded) val time = innerJson.getString("AbsoluteTime") 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 002a66f..f28baae 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 @@ -3,12 +3,17 @@ package com.amazon.connect.chat.sdk.repository import android.util.Log import com.amazon.connect.chat.sdk.model.ChatDetails import com.amazon.connect.chat.sdk.model.ChatEvent +import com.amazon.connect.chat.sdk.model.ContentType +import com.amazon.connect.chat.sdk.model.Event import com.amazon.connect.chat.sdk.model.GlobalConfig +import com.amazon.connect.chat.sdk.model.Message +import com.amazon.connect.chat.sdk.model.MessageMetadata import com.amazon.connect.chat.sdk.model.MetricName import com.amazon.connect.chat.sdk.model.TranscriptItem import com.amazon.connect.chat.sdk.network.AWSClient import com.amazon.connect.chat.sdk.network.MetricsManager import com.amazon.connect.chat.sdk.network.WebSocketManager +import com.amazon.connect.chat.sdk.utils.Constants import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -34,13 +39,15 @@ interface ChatService { val eventPublisher: SharedFlow val transcriptPublisher: SharedFlow + val transcriptListPublisher : SharedFlow> + val chatSessionStatePublisher: SharedFlow } class ChatServiceImpl @Inject constructor( private val awsClient: AWSClient, private val connectionDetailsProvider: ConnectionDetailsProvider, private val webSocketManager: WebSocketManager, - private val metricsManager: MetricsManager + private val metricsManager: MetricsManager, ) : ChatService { private val coroutineScope = CoroutineScope(Dispatchers.Main) @@ -53,16 +60,26 @@ 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 _chatSessionStatePublisher = MutableSharedFlow() + override val chatSessionStatePublisher: SharedFlow get() = _chatSessionStatePublisher + private var chatSessionStateCollectionJob: Job? = null + + private var transcriptDict = mutableMapOf() + private var internalTranscript = mutableListOf() + override fun configure(config: GlobalConfig) { awsClient.configure(config) } init { - setupEventSubscriptions() registerNotificationListeners() } override suspend fun createChatSession(chatDetails: ChatDetails): Result { + setupEventSubscriptions() return runCatching { connectionDetailsProvider.updateChatDetails(chatDetails) val connectionDetails = awsClient.createParticipantConnection(chatDetails.participantToken).getOrThrow() @@ -84,13 +101,13 @@ class ChatServiceImpl @Inject constructor( } private fun setupEventSubscriptions() { - transcriptCollectionJob?.cancel() - eventCollectionJob?.cancel() + clearSubscriptionsAndPublishers() eventCollectionJob = coroutineScope.launch { webSocketManager.eventPublisher.collect { event -> when (event) { ChatEvent.ConnectionEstablished -> { + connectionDetailsProvider.setChatSessionState(true) Log.d("ChatServiceImpl", "Connection Established") } @@ -98,7 +115,11 @@ class ChatServiceImpl @Inject constructor( Log.d("ChatServiceImpl", "Connection Re-Established") } - ChatEvent.ChatEnded -> Log.d("ChatServiceImpl", "Chat Ended") + ChatEvent.ChatEnded -> { + Log.d("ChatServiceImpl", "Chat Ended") + connectionDetailsProvider.setChatSessionState(false) + } + ChatEvent.ConnectionBroken -> Log.d("ChatServiceImpl", "Connection Broken") } _eventPublisher.emit(event) @@ -107,9 +128,88 @@ class ChatServiceImpl @Inject constructor( transcriptCollectionJob = coroutineScope.launch { webSocketManager.transcriptPublisher.collect { transcriptItem -> - _transcriptPublisher.emit(transcriptItem) + updateTranscriptDict(transcriptItem) + } + } + + chatSessionStateCollectionJob = coroutineScope.launch { + connectionDetailsProvider.chatSessionState.collect { isActive -> + _chatSessionStatePublisher.emit(isActive) + } + } + } + + private fun updateTranscriptDict(item: TranscriptItem) { + when(item) { + is MessageMetadata -> { + // Associate metadata with message based on its ID + val messageItem = transcriptDict[item.id] as? Message + messageItem?.let { + it.metadata = item + transcriptDict[item.id] = it + } + } + is Message -> { + // Remove typing indicators when a new message from the agent is received + if (item.participant == Constants.AGENT) { + removeTypingIndicators() + } + + // TODO ; Handle temporary attachment here + + transcriptDict[item.id] = item + } + is Event -> { + handleEvent(item, transcriptDict) } } + + transcriptDict[item.id]?.let { + handleTranscriptItemUpdate(it) + } + + } + + private fun removeTypingIndicators() { + // TODO + // TODO("removeTypingIndicators: Not yet implemented") + } + + private fun handleEvent(event: Event, currentDict: MutableMap) { + if (event.contentType == ContentType.TYPING.type) { + // TODO ; reset typing timer + } + currentDict[event.id] = event + } + + + private fun handleTranscriptItemUpdate(item: TranscriptItem) { + // Send out the individual transcript item to subscribers + coroutineScope.launch { + _transcriptPublisher.emit(item) + } + + // Update the internal transcript list with the new or updated item + val existingIndex = internalTranscript.indexOfFirst { it.id == item.id } + if (existingIndex != -1) { + // If the item already exists in the internal transcript list, update it + internalTranscript[existingIndex] = item + } else { + // If the item is new, determine where to insert it in the list based on its timestamp + if (internalTranscript.isEmpty() || item.timeStamp < internalTranscript.first().timeStamp) { + // If the list is empty or the new item is older than the first item, add it to the beginning + internalTranscript.add(0, item) + } else { + // Otherwise, add it to the end of the list + internalTranscript.add(item) + } + } + Log.d("ChatServiceImpl", "Updated transcript: $internalTranscript") + + // Send the updated transcript list to subscribers + coroutineScope.launch { + _transcriptListPublisher.emit(internalTranscript) + } } override suspend fun disconnectChatSession(): Result { @@ -118,6 +218,7 @@ class ChatServiceImpl @Inject constructor( ?: throw Exception("No connection details available") awsClient.disconnectParticipantConnection(connectionDetails.connectionToken).getOrThrow() Log.d("ChatServiceImpl", "Participant Disconnected") + connectionDetailsProvider.setChatSessionState(false) clearSubscriptionsAndPublishers() true }.onFailure { exception -> @@ -127,8 +228,8 @@ class ChatServiceImpl @Inject constructor( private fun registerNotificationListeners() { + Log.d("ChatServiceImpl", "registerNotificationListeners") coroutineScope.launch { - Log.d("ChatServiceImpl", "registerNotificationListeners") webSocketManager.requestNewWsUrlFlow.collect{ handleNewWsUrlRequest() } @@ -161,6 +262,14 @@ class ChatServiceImpl @Inject constructor( private fun clearSubscriptionsAndPublishers() { transcriptCollectionJob?.cancel() eventCollectionJob?.cancel() + chatSessionStateCollectionJob?.cancel() + + transcriptCollectionJob = null + eventCollectionJob = null + chatSessionStateCollectionJob = null + + transcriptDict = mutableMapOf() + internalTranscript = mutableListOf() } } \ No newline at end of file diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/repository/ConnectionDetailProvider.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/repository/ConnectionDetailProvider.kt index ac5bfee..1369004 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/repository/ConnectionDetailProvider.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/repository/ConnectionDetailProvider.kt @@ -2,6 +2,8 @@ package com.amazon.connect.chat.sdk.repository import com.amazon.connect.chat.sdk.model.ChatDetails import com.amazon.connect.chat.sdk.model.ConnectionDetails +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject import javax.inject.Singleton @@ -12,13 +14,20 @@ interface ConnectionDetailsProvider { fun getChatDetails(): ChatDetails? fun isChatSessionActive(): Boolean fun setChatSessionState(isActive: Boolean) + var chatSessionState: StateFlow } @Singleton class ConnectionDetailsProviderImpl @Inject constructor() : ConnectionDetailsProvider { + + private val _chatSessionState = MutableStateFlow(false) + override var chatSessionState: StateFlow = _chatSessionState + + @Volatile private var connectionDetails: ConnectionDetails? = null + + @Volatile private var chatDetails: ChatDetails? = null - private var isChatActive: Boolean = false override fun updateConnectionDetails(newDetails: ConnectionDetails) { connectionDetails = newDetails @@ -37,10 +46,10 @@ class ConnectionDetailsProviderImpl @Inject constructor() : ConnectionDetailsPro } override fun isChatSessionActive(): Boolean { - return isChatActive + return _chatSessionState.value } override fun setChatSessionState(isActive: Boolean) { - isChatActive = isActive + _chatSessionState.value = isActive } } \ No newline at end of file diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/utils/CommonUtils.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/utils/CommonUtils.kt index 8ab156f..a428860 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/utils/CommonUtils.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/utils/CommonUtils.kt @@ -21,27 +21,9 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.sp import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone class CommonUtils { companion object{ - fun formatTime(timeStamp: String): String { - val utcFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { - timeZone = TimeZone.getTimeZone("UTC") - } - - val date = utcFormatter.parse(timeStamp) - return if (date != null) { - val localFormatter = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { - timeZone = TimeZone.getDefault() - } - localFormatter.format(date) - } else { - timeStamp - } - } @Composable fun keyboardAsState(): State { 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 cd4fe3f..03e9aee 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 @@ -1,8 +1,13 @@ package com.amazon.connect.chat.sdk.repository +import android.os.Looper +import androidx.compose.animation.fadeIn import com.amazon.connect.chat.sdk.model.ChatDetails +import com.amazon.connect.chat.sdk.model.ChatEvent import com.amazon.connect.chat.sdk.model.ConnectionDetails import com.amazon.connect.chat.sdk.model.GlobalConfig +import com.amazon.connect.chat.sdk.model.Message +import com.amazon.connect.chat.sdk.model.TranscriptItem import com.amazon.connect.chat.sdk.network.APIClient import com.amazon.connect.chat.sdk.network.AWSClient import com.amazon.connect.chat.sdk.network.MetricsInterface @@ -10,8 +15,18 @@ import com.amazon.connect.chat.sdk.network.MetricsManager import com.amazon.connect.chat.sdk.network.WebSocketManager import com.amazonaws.regions.Regions import com.amazonaws.services.connectparticipant.model.DisconnectParticipantResult +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNull import junit.framework.TestCase.assertTrue import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test @@ -20,6 +35,7 @@ import org.mockito.Mock import org.mockito.Mockito.* import org.mockito.MockitoAnnotations import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf @ExperimentalCoroutinesApi @RunWith(RobolectricTestRunner::class) @@ -41,10 +57,25 @@ class ChatServiceImplTest { private lateinit var metricsManager: MetricsManager private lateinit var chatService: ChatService + private lateinit var eventSharedFlow: MutableSharedFlow + private lateinit var transcriptSharedFlow: MutableSharedFlow + private lateinit var chatSessionStateFlow: MutableStateFlow + private lateinit var transcriptListSharedFlow: MutableSharedFlow> + @Before fun setUp() { MockitoAnnotations.openMocks(this) + + eventSharedFlow = MutableSharedFlow() + transcriptSharedFlow = MutableSharedFlow() + transcriptListSharedFlow = MutableSharedFlow() + chatSessionStateFlow = MutableStateFlow(false) + + `when`(webSocketManager.eventPublisher).thenReturn(eventSharedFlow) + `when`(webSocketManager.transcriptPublisher).thenReturn(transcriptSharedFlow) + `when`(connectionDetailsProvider.chatSessionState).thenReturn(chatSessionStateFlow) + chatService = ChatServiceImpl(awsClient, connectionDetailsProvider, webSocketManager, metricsManager) } @@ -127,4 +158,76 @@ class ChatServiceImplTest { ) } + @Test + fun test_eventPublisher_emitsCorrectEvent() = runTest { + val chatEvent = ChatEvent.ConnectionEstablished + + // Launch the flow collection within the test's coroutine scope + val job = chatService.eventPublisher + .onEach { event -> + assertEquals(chatEvent, event) + } + .launchIn(this) + + // Emit the event + eventSharedFlow.emit(chatEvent) + + // Cancel the job after testing to ensure the coroutine completes + job.cancel() + } + + @Test + fun test_transcriptPublisher_emitsCorrectTranscriptItem() = runTest { + val transcriptItem = Message(id = "1", timeStamp = "mockedTimestamp", participant = "user", + contentType = "text/plain", text = "Hello") + + val job = chatService.transcriptPublisher + .onEach { item -> + assertEquals(transcriptItem, item) + } + .launchIn(this) + + // Emit the transcript item + transcriptSharedFlow.emit(transcriptItem) + + // Cancel the job after testing to ensure the coroutine completes + job.cancel() + } + + @Test + fun test_transcriptListPublisher_emitsTranscriptList() = runTest { + 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) + } + .launchIn(this) + + // Emit the transcript list + transcriptListSharedFlow.emit(transcriptList) + + // Cancel the job after testing to ensure the coroutine completes + job.cancel() + } + + @Test + fun test_chatSessionStatePublisher_emitsSessionState() = runTest { + // Launch the flow collection within the test's coroutine scope + val job = chatService.chatSessionStatePublisher + .onEach { isActive -> + assertTrue(isActive) + } + .launchIn(this) + + // Emit the session state + chatSessionStateFlow.emit(true) + + // Cancel the job after testing to ensure the coroutine completes + job.cancel() + } + } \ No newline at end of file