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 114f9fd..f2a922b 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 @@ -397,7 +397,7 @@ fun ChatView(viewModel: ChatViewModel, activity: Activity) { if(!selectedFileName?.lastPathSegment.isNullOrEmpty()) { selectedFileName?.let { viewModel.uploadAttachment(it) } } - if (textInput.isNotEmpty()) { + if (textInput.trim().isNotEmpty()) { viewModel.sendMessage(textInput) } textInput = "" 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 67fc560..523a1ce 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 @@ -89,6 +89,7 @@ object CommonUtils { MessageStatus.Sending -> "Sending" MessageStatus.Failed -> "Failed to send" MessageStatus.Sent -> "Sent" + MessageStatus.Custom -> status.customValue ?: "Custom status" else -> "" // Returning empty string for unknown or null status } } 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 5bf126c..c00b3eb 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 @@ -1,6 +1,7 @@ package com.amazon.connect.chat.androidchatexample.viewmodel import android.app.Activity +import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.net.Uri @@ -19,6 +20,7 @@ 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.ChatSession +import com.amazon.connect.chat.sdk.ChatSessionProvider import com.amazon.connect.chat.sdk.model.ChatDetails import com.amazon.connect.chat.sdk.model.ContentType import com.amazon.connect.chat.sdk.model.Event @@ -29,6 +31,7 @@ import com.amazon.connect.chat.sdk.model.TranscriptItem import com.amazonaws.services.connectparticipant.model.ScanDirection import com.amazonaws.services.connectparticipant.model.SortKey import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.launch import java.net.URL import javax.inject.Inject @@ -40,12 +43,17 @@ class ChatViewModel @Inject constructor( private val sharedPreferences: SharedPreferences, private val chatConfigProvider: ChatConfigProvider ) : ViewModel() { + + // If you are not using Hilt, you can initialize ChatSession like this +// private val chatSession = ChatSessionProvider.getChatSession(context) + private val _isLoading = MutableLiveData(false) val isLoading: MutableLiveData = _isLoading private val _isChatActive = MutableLiveData(false) val isChatActive: MutableLiveData = _isChatActive + private val _selectedFileUri = MutableLiveData(Uri.EMPTY) val selectedFileUri: MutableLiveData = _selectedFileUri @@ -106,7 +114,7 @@ class ChatViewModel @Inject constructor( } chatSession.onTranscriptUpdated = { transcriptList -> - Log.d("ChatViewModel", "Transcript onTranscriptUpdated: $transcriptList") + Log.d("ChatViewModel", "Transcript onTranscriptUpdated last 3 items: ${transcriptList.takeLast(3)}") viewModelScope.launch { onUpdateTranscript(transcriptList) } @@ -130,6 +138,10 @@ class ChatViewModel @Inject constructor( Log.d("ChatViewModel", "Chat session state changed: $it") _isChatActive.value = it } + + chatSession.onDeepHeartBeatFailure = { + Log.d("ChatViewModel", "Deep heartbeat failure") + } } fun initiateChat() { @@ -217,7 +229,6 @@ class ChatViewModel @Inject constructor( transcriptItem } messages.addAll(updatedMessages) - Log.d("ChatViewModel", "Transcript updated: $messages") } } diff --git a/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentMessageView.kt b/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentMessageView.kt index 63267a2..61161b3 100644 --- a/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentMessageView.kt +++ b/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentMessageView.kt @@ -153,7 +153,9 @@ fun AttachmentMessageView( Text( text = CommonUtils.customMessageStatus(message.metadata?.status), fontSize = 10.sp, - color = Color.Gray + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } 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 781c979..1f5d399 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 @@ -104,9 +104,11 @@ interface ChatSession { var onConnectionEstablished: (() -> Unit)? var onConnectionReEstablished: (() -> Unit)? var onConnectionBroken: (() -> Unit)? + var onDeepHeartBeatFailure: (() -> Unit)? var onMessageReceived: ((TranscriptItem) -> Unit)? var onTranscriptUpdated: ((List) -> Unit)? var onChatEnded: (() -> Unit)? + var isChatSessionActive: Boolean } @Singleton @@ -115,17 +117,18 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) override var onConnectionEstablished: (() -> Unit)? = null override var onConnectionReEstablished: (() -> Unit)? = null override var onConnectionBroken: (() -> Unit)? = null + override var onDeepHeartBeatFailure: (() -> 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 + override var isChatSessionActive: Boolean = false 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 - private fun setupEventSubscriptions() { // Cancel any existing subscriptions before setting up new ones cleanup() @@ -136,8 +139,12 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) when (event) { ChatEvent.ConnectionEstablished -> onConnectionEstablished?.invoke() ChatEvent.ConnectionReEstablished -> onConnectionReEstablished?.invoke() - ChatEvent.ChatEnded -> onChatEnded?.invoke() + ChatEvent.ChatEnded -> { + onChatEnded?.invoke() + cleanup() + } ChatEvent.ConnectionBroken -> onConnectionBroken?.invoke() + ChatEvent.DeepHeartBeatFailure -> onDeepHeartBeatFailure?.invoke() } } } @@ -164,6 +171,7 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) chatSessionStateCollectionJob = coroutineScope.launch { chatService.chatSessionStatePublisher.collect { isActive -> + isChatSessionActive = isActive onChatSessionStateChanged?.invoke(isActive) } } @@ -184,8 +192,6 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) override suspend fun disconnect(): Result { return withContext(Dispatchers.IO) { chatService.disconnectChatSession() - }.also { - cleanup() } } @@ -201,18 +207,6 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) } } - private fun cleanup() { - // 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 - } override suspend fun sendAttachment(fileUri: Uri): Result { return withContext(Dispatchers.IO) { @@ -274,7 +268,6 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) } } - private suspend fun sendReceipt(event: MessageReceiptType, messageId: String): Result { return withContext(Dispatchers.IO) { runCatching { @@ -286,4 +279,20 @@ class ChatSessionImpl @Inject constructor(private val chatService: ChatService) } } + private fun cleanup() { + // 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 + + // Reset active state + isChatSessionActive = false + onChatSessionStateChanged?.invoke(false) + } } \ No newline at end of file diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/ChatSessionProvider.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/ChatSessionProvider.kt new file mode 100644 index 0000000..f5883e2 --- /dev/null +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/ChatSessionProvider.kt @@ -0,0 +1,29 @@ +package com.amazon.connect.chat.sdk + +import android.content.Context +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface ChatSessionEntryPoint { + fun getChatSession(): ChatSession +} + +object ChatSessionProvider { + private var chatSession: ChatSession? = null + + fun getChatSession(context: Context): ChatSession { + if (chatSession == null) { + val appContext = context.applicationContext + val entryPoint = EntryPointAccessors.fromApplication( + appContext, + ChatSessionEntryPoint::class.java + ) + chatSession = entryPoint.getChatSession() + } + return chatSession!! + } +} \ No newline at end of file diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/Config.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/Config.kt deleted file mode 100644 index cc6b2ec..0000000 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/Config.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.amazon.connect.chat.sdk - -import com.amazonaws.regions.Regions - -object Config { - val connectInstanceId: String = "e816d0f3-eda3-46e4-bc67-9999e621eff6" - val contactFlowId: String = "f22bfa3b-400e-4250-939d-90a79eb1cd24" - val startChatEndpoint: String = "https://bqo00ujzld.execute-api.us-west-2.amazonaws.com/" - val region: Regions = Regions.US_WEST_2 - val agentName = "AGENT" - val customerName = "CUSTOMER" - - // TODO: Find home for non-feature configs in SDK - val isDevMode: Boolean = true - val disableCsm: Boolean = false -} \ No newline at end of file diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/ContentType.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/ContentType.kt index 9f75a79..e6527f2 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/ContentType.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/ContentType.kt @@ -37,4 +37,5 @@ enum class ChatEvent { ConnectionReEstablished, ChatEnded, ConnectionBroken, + DeepHeartBeatFailure, } diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/GlobalConfig.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/GlobalConfig.kt index 163022f..e6c0cc3 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/GlobalConfig.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/GlobalConfig.kt @@ -6,8 +6,7 @@ import com.amazonaws.regions.Regions data class GlobalConfig( var region: Regions = defaultRegion, var features: Features = Features.defaultFeatures, - var disableCsm: Boolean = false, - var isDevMode: Boolean = false + var disableCsm: Boolean = false ) { companion object { val defaultRegion: Regions diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/MessageMetadata.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/MessageMetadata.kt index 919154c..ead8316 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/MessageMetadata.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/model/MessageMetadata.kt @@ -1,12 +1,23 @@ package com.amazon.connect.chat.sdk.model -enum class MessageStatus(val status: String) { +enum class MessageStatus(val status: String, var customValue: String? = null) { Delivered("Delivered"), Read("Read"), Sending("Sending"), Failed("Failed"), Sent("Sent"), - Unknown("Unknown") + Unknown("Unknown"), + + Custom("Custom", null); + + companion object { + fun custom(message: String): MessageStatus { + return Custom.apply { + Custom.customValue = message + } + } + } + } interface MessageMetadataProtocol : TranscriptItemProtocol { diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/HeartbeatManager.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/HeartbeatManager.kt index f685f63..858a12b 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/HeartbeatManager.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/HeartbeatManager.kt @@ -1,16 +1,19 @@ package com.amazon.connect.chat.sdk.network +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlin.concurrent.timer import java.util.Timer class HeartbeatManager( val sendHeartbeatCallback: () -> Unit, - val missedHeartbeatCallback: () -> Unit + val missedHeartbeatCallback: suspend () -> Unit ) { private var pendingResponse: Boolean = false private var timer: Timer? = null - fun startHeartbeat() { + suspend fun startHeartbeat() { timer?.cancel() pendingResponse = false timer = timer(period = 10000) { @@ -19,7 +22,9 @@ class HeartbeatManager( pendingResponse = true } else { timer?.cancel() - missedHeartbeatCallback() + CoroutineScope(Dispatchers.IO).launch { + missedHeartbeatCallback() + } } } } diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/MetricsManager.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/MetricsManager.kt index 8b4897f..64182dd 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/MetricsManager.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/network/MetricsManager.kt @@ -8,6 +8,7 @@ import com.amazon.connect.chat.sdk.utils.MetricsUtils import com.amazon.connect.chat.sdk.model.MetricName import com.amazon.connect.chat.sdk.model.Metric import com.amazon.connect.chat.sdk.model.Dimension +import com.amazon.connect.chat.sdk.model.GlobalConfig class MetricsManager @Inject constructor( private var apiClient: APIClient @@ -16,13 +17,18 @@ class MetricsManager @Inject constructor( private var isMonitoring: Boolean = false private var timer: Timer? = null private var shouldRetry: Boolean = true + private var _isCsmDisabled: Boolean = false init { - if (!MetricsUtils.isCsmDisabled()) { + if (!_isCsmDisabled) { monitorAndSendMetrics() } } + fun configure(config: GlobalConfig) { + _isCsmDisabled = config.disableCsm + } + @Synchronized private fun monitorAndSendMetrics() { if (isMonitoring) return @@ -85,7 +91,7 @@ class MetricsManager @Inject constructor( } private fun addMetric(metric: Metric) { - if (MetricsUtils.isCsmDisabled()) { + if (_isCsmDisabled) { return } 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 9ce09b8..c749486 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 @@ -13,11 +13,13 @@ import com.amazon.connect.chat.sdk.model.MessageMetadata import com.amazon.connect.chat.sdk.model.MessageStatus import com.amazon.connect.chat.sdk.model.TranscriptItem import com.amazon.connect.chat.sdk.model.WebSocketMessageType +import com.amazon.connect.chat.sdk.utils.logger.SDKLogger import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient @@ -42,6 +44,7 @@ interface WebSocketManager { val eventPublisher: SharedFlow val transcriptPublisher: SharedFlow val requestNewWsUrlFlow: MutableSharedFlow + var isReconnecting: MutableStateFlow suspend fun connect(wsUrl: String, isReconnectFlow: Boolean = false) suspend fun disconnect() suspend fun parseTranscriptItemFromJson(jsonString: String): TranscriptItem? @@ -61,7 +64,14 @@ class WebSocketManagerImpl @Inject constructor( private var webSocket: WebSocket? = null private var isConnectedToNetwork: Boolean = false private var isChatActive: Boolean = false - private var isReconnecting: Boolean = false + + private val _isReconnecting = MutableStateFlow(false) + override var isReconnecting: MutableStateFlow + get() = _isReconnecting + set(value) { + _isReconnecting.value = value.value + } + private var heartbeatManager: HeartbeatManager = HeartbeatManager( sendHeartbeatCallback = ::sendHeartbeat, @@ -150,27 +160,27 @@ class WebSocketManagerImpl @Inject constructor( // --- WebSocket Listener --- private fun createWebSocketListener(isReconnectFlow: Boolean) = object : WebSocketListener() { - override fun onOpen(ws: WebSocket, response: Response) { + override fun onOpen(webSocket: WebSocket, response: Response) { coroutineScope.launch { handleWebSocketOpen(isReconnectFlow) } } - override fun onMessage(ws: WebSocket, text: String) { + override fun onMessage(webSocket: WebSocket, text: String) { coroutineScope.launch { processJsonContent(text) } } - override fun onClosing(ws: WebSocket, code: Int, reason: String) { + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { Log.i("WebSocket", "WebSocket is closing with code: $code, reason: $reason") } - override fun onClosed(ws: WebSocket, code: Int, reason: String) { + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { handleWebSocketClosed(code, reason) } - override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { handleWebSocketFailure(t) } } @@ -178,7 +188,7 @@ class WebSocketManagerImpl @Inject constructor( private suspend fun handleWebSocketOpen(isReconnectFlow: Boolean) { sendMessage(EventTypes.subscribe) startHeartbeats() - isReconnecting = false + _isReconnecting.value = false // Reconnection successful, reset flag isChatActive = true if (isReconnectFlow) { this._eventPublisher.emit(ChatEvent.ConnectionReEstablished) @@ -197,6 +207,7 @@ class WebSocketManagerImpl @Inject constructor( } private fun handleWebSocketFailure(t: Throwable) { + Log.e("WebSocket", "WebSocket failure: ${t.message}") if (t is IOException && t.message == "Software caused connection abort") { if (isChatActive && isConnectedToNetwork) { reestablishConnection() @@ -228,7 +239,10 @@ class WebSocketManagerImpl @Inject constructor( when (topic) { "aws/ping" -> handlePing(json) "aws/heartbeat" -> handleHeartbeat() - "aws/chat" -> handleWebsocketMessage(json.optString("content")) + "aws/chat" -> { + SDKLogger.logger.logDebug { "Received chat message from websocket $json" } + handleWebsocketMessage(json.optString("content")) + } else -> Log.i("WebSocket", "Unhandled topic: $topic") } } @@ -295,7 +309,7 @@ class WebSocketManagerImpl @Inject constructor( deepHeartbeatManager.stopHeartbeat() } - private fun startHeartbeats() { + private suspend fun startHeartbeats() { heartbeatManager.startHeartbeat() deepHeartbeatManager.startHeartbeat() } @@ -316,7 +330,8 @@ class WebSocketManagerImpl @Inject constructor( } } - private fun onDeepHeartbeatMissed() { + private suspend fun onDeepHeartbeatMissed() { + this._eventPublisher.emit(ChatEvent.DeepHeartBeatFailure) if (isConnectedToNetwork) { reestablishConnection() Log.w("WebSocket", "Deep Heartbeat missed, retrying connection") @@ -333,16 +348,15 @@ class WebSocketManagerImpl @Inject constructor( // --- Helper Methods --- private fun reestablishConnection() { - if (!isReconnecting) { + if (!_isReconnecting.value) { + _isReconnecting.value = true requestNewWsUrl() - isReconnecting = true } } private fun requestNewWsUrl() { CoroutineScope(Dispatchers.IO).launch { requestNewWsUrlFlow.emit(Unit) - isReconnecting = false } } @@ -361,7 +375,6 @@ class WebSocketManagerImpl @Inject constructor( val displayName = innerJson.getString("DisplayName") val time = innerJson.getString("AbsoluteTime") - // TODO: Pass raw data val message = Message( participant = participantRole, text = messageText, @@ -385,7 +398,7 @@ class WebSocketManagerImpl @Inject constructor( timeStamp = time, displayName = displayName, participant = participantRole, - text = innerJson.getString("ContentType"), // TODO: Need to be removed and replaced in UI once callbacks are hooked + text = innerJson.getString("ContentType"), contentType = innerJson.getString("ContentType"), eventDirection = MessageDirection.COMMON, serializedContent = rawData 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 b769cf6..ffeaaaa 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,6 +3,13 @@ package com.amazon.connect.chat.sdk.repository import android.content.Context import android.net.Uri import android.util.Log +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.OnLifecycleEvent +import androidx.lifecycle.ProcessLifecycleOwner import com.amazon.connect.chat.sdk.model.ChatDetails import com.amazon.connect.chat.sdk.model.ChatEvent import com.amazon.connect.chat.sdk.model.ContentType @@ -130,7 +137,7 @@ class ChatServiceImpl @Inject constructor( private val metricsManager: MetricsManager, private val attachmentsManager: AttachmentsManager, private val messageReceiptsManager: MessageReceiptsManager -) : ChatService { +) : ChatService, DefaultLifecycleObserver { private val coroutineScope = CoroutineScope(Dispatchers.Main) @@ -161,6 +168,7 @@ class ChatServiceImpl @Inject constructor( override fun configure(config: GlobalConfig) { awsClient.configure(config) + metricsManager.configure(config) } init { @@ -209,6 +217,7 @@ class ChatServiceImpl @Inject constructor( ChatEvent.ConnectionReEstablished -> { SDKLogger.logger.logDebug { "Connection Re-Established" } connectionDetailsProvider.setChatSessionState(true) + removeTypingIndicators() // Make sure to remove typing indicators if still present fetchReconnectedTranscript(internalTranscript) } @@ -218,8 +227,16 @@ class ChatServiceImpl @Inject constructor( } ChatEvent.ConnectionBroken -> SDKLogger.logger.logDebug { "Connection Broken" } + ChatEvent.DeepHeartBeatFailure -> { + SDKLogger.logger.logDebug { "Deep Heartbeat Failure" } + } } _eventPublisher.emit(event) + + // Cleanup when chat ends, making sure that it cleanups after event has been emitted + if (event == ChatEvent.ChatEnded) { + clearSubscriptionsAndPublishers() + } } } @@ -283,13 +300,12 @@ class ChatServiceImpl @Inject constructor( val initialCount = transcriptDict.size // Remove typing indicators from both transcriptDict and internalTranscript - val keysToRemove = transcriptDict.filterValues { - it is Event && it.contentType == ContentType.TYPING.type - }.keys + transcriptDict.entries.removeIf { + it.value is Event && it.value.contentType == ContentType.TYPING.type + } - keysToRemove.forEach { key -> - transcriptDict.remove(key) - internalTranscript.removeAll { item -> item.id == key } + internalTranscript.removeIf { + it is Event && it.contentType == ContentType.TYPING.type } // Send the updated transcript list to subscribers if items removed @@ -300,11 +316,14 @@ class ChatServiceImpl @Inject constructor( } } + private fun resetTypingIndicatorTimer(after: Double = 0.0) { typingIndicatorTimer?.cancel() typingIndicatorTimer = Timer().apply { schedule(after.toLong() * 1000) { - removeTypingIndicators() + if (isAppInForeground()) { + removeTypingIndicators() + } } } } @@ -325,6 +344,18 @@ class ChatServiceImpl @Inject constructor( // Update the internal transcript list with the new or updated item val existingIndex = internalTranscript.indexOfFirst { it.id == item.id } if (existingIndex != -1) { + val existingItem = internalTranscript[existingIndex] + + // Reapply the metadata to the new item + // Whenever new items comes from getTranscript, it comes with null metadata, but we + // already have that item in our internalTranscript, so we will just apply existing + // metadata to new item. + if (existingItem is Message && item is Message) { + if (existingItem.metadata != null && item.metadata == null) { + item.metadata = existingItem.metadata + } + } + // If the item already exists in the internal transcript list, update it internalTranscript[existingIndex] = item } else { @@ -392,7 +423,6 @@ class ChatServiceImpl @Inject constructor( .getOrThrow() SDKLogger.logger.logDebug { "Participant Disconnected" } connectionDetailsProvider.setChatSessionState(false) - clearSubscriptionsAndPublishers() true }.onFailure { exception -> SDKLogger.logger.logError { "Failed to disconnect participant: ${exception.message}" } @@ -460,6 +490,9 @@ class ChatServiceImpl @Inject constructor( } private fun registerNotificationListeners() { + // Observe lifecycle events + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + coroutineScope.launch { webSocketManager.requestNewWsUrlFlow.collect { handleNewWsUrlRequest() @@ -467,6 +500,17 @@ class ChatServiceImpl @Inject constructor( } } + override fun onStop(owner: LifecycleOwner) { + // Called when the app goes to the background + typingIndicatorTimer?.cancel() + removeTypingIndicators() + } + + private fun isAppInForeground(): Boolean { + return ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + } + + private fun getRecentDisplayName(): String { val recentCustomerMessage = transcriptDict.values .filterIsInstance() @@ -475,6 +519,8 @@ class ChatServiceImpl @Inject constructor( } private suspend fun handleNewWsUrlRequest() { + webSocketManager.isReconnecting.value = true + val chatDetails = connectionDetailsProvider.getChatDetails() chatDetails?.let { val result = awsClient.createParticipantConnection(it.participantToken) @@ -492,25 +538,14 @@ class ChatServiceImpl @Inject constructor( } SDKLogger.logger.logError { "CreateParticipantConnection failed: $error" } } + webSocketManager.isReconnecting.value = false + } ?: run { + // No chat details available, mark reconnection as failed + webSocketManager.isReconnecting.value = false + SDKLogger.logger.logError { "Failed to retrieve chat details." } } } - private fun clearSubscriptionsAndPublishers() { - transcriptCollectionJob?.cancel() - eventCollectionJob?.cancel() - chatSessionStateCollectionJob?.cancel() - - transcriptCollectionJob = null - eventCollectionJob = null - chatSessionStateCollectionJob = null - - transcriptDict = mutableMapOf() - internalTranscript = mutableListOf() - - typingIndicatorTimer?.cancel() - throttleTypingEventTimer?.cancel() - } - override suspend fun sendAttachment(fileUri: Uri): Result { var recentlySentAttachmentMessage: Message? = null @@ -542,7 +577,8 @@ class ChatServiceImpl @Inject constructor( // Update the recentlySentAttachmentMessage with a failure status if the message was created SDKLogger.logger.logError { "Failed to send attachment: ${exception.message}" } recentlySentAttachmentMessage?.let { - it.metadata?.status = MessageStatus.Failed + it.metadata?.status = + MessageStatus.custom(exception.message ?: "Failed to send attachment") sendSingleUpdateToClient(it) SDKLogger.logger.logError { "Message status updated to Failed: $it" } } @@ -625,6 +661,10 @@ class ChatServiceImpl @Inject constructor( } } } + + SDKLogger.logger.logDebug { "Transcript fetched successfully" } + SDKLogger.logger.logDebug { "Transcript Items: $formattedItems" } + // Create and return the TranscriptResponse TranscriptResponse( initialContactId = response.initialContactId.orEmpty(), @@ -695,4 +735,21 @@ class ChatServiceImpl @Inject constructor( Result.success(Unit) } } + + private fun clearSubscriptionsAndPublishers() { + transcriptCollectionJob?.cancel() + eventCollectionJob?.cancel() + chatSessionStateCollectionJob?.cancel() + + transcriptCollectionJob = null + eventCollectionJob = null + chatSessionStateCollectionJob = null + + transcriptDict = mutableMapOf() + internalTranscript = mutableListOf() + + typingIndicatorTimer?.cancel() + throttleTypingEventTimer?.cancel() + } + } \ No newline at end of file diff --git a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/utils/MetricsUtils.kt b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/utils/MetricsUtils.kt index 498df15..2c699ee 100644 --- a/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/utils/MetricsUtils.kt +++ b/chat-sdk/src/main/java/com/amazon/connect/chat/sdk/utils/MetricsUtils.kt @@ -4,8 +4,6 @@ package com.amazon.connect.chat.sdk.utils import java.text.SimpleDateFormat import java.util.* -import com.amazon.connect.chat.sdk.Config -import com.amazon.connect.chat.sdk.network.MetricsInterface object MetricsUtils { fun getCurrentMetricTimestamp(): String { @@ -15,15 +13,7 @@ object MetricsUtils { return formatter.format(now) } - fun isCsmDisabled(): Boolean { - return Config.disableCsm - } - fun getMetricsEndpoint(): String { - return if (Config.isDevMode) { - "https://f9cskafqk3.execute-api.us-west-2.amazonaws.com/devo/" - } else { - "https://ieluqbvv.telemetry.connect.us-west-2.amazonaws.com/prod/" - } + return "https://ieluqbvv.telemetry.connect.us-west-2.amazonaws.com/prod/" } }