diff --git a/mobileChatExamples/androidChatExample/README.md b/mobileChatExamples/androidChatExample/README.md index b2f9cc0..aae606d 100644 --- a/mobileChatExamples/androidChatExample/README.md +++ b/mobileChatExamples/androidChatExample/README.md @@ -1,20 +1,27 @@ # Android Native Chat Demo 📱 -A native android example app for building custom Amazon Connect Chat. This solution implements basic [ChatWidget](https://docs.aws.amazon.com/connect/latest/adminguide/add-chat-to-website.html) functionality and is capable of Interactive Messages. +This is an example app on how to utilize [ Amazon Connect Chat SDK ](https://github.com/amazon-connect/amazon-connect-chat-android) + > Refer to [#Specifications](#speficications) for details on compatibility, supported versions, and platforms. **Reference:** -- Documentation: https://docs.aws.amazon.com/connect/latest/adminguide/enable-chat-in-app.html +- Admin guide: [https://docs.aws.amazon.com/connect/latest/adminguide/enable-chat-in-app.html](https://docs.aws.amazon.com/connect/latest/adminguide/integrate-chat-with-mobile.html) +- SDK Documentation: https://github.com/amazon-connect/amazon-connect-chat-android/blob/main/README.md + + +### Demo: + +https://github.com/user-attachments/assets/216d9df8-63ad-473f-a14f-9bc7c5ed3ec3 + -https://github.com/amazon-connect/amazon-connect-chat-ui-examples/assets/143978428/1298b153-f476-48d8-aa36-605f3642103a ## Contents - [Prerequisites](#prerequisites) - [Local Development](#local-development) -- [How is it working?](#how-is-it-working) +- [Implementation](#implementation) ## Prerequisites @@ -51,83 +58,218 @@ https://github.com/amazon-connect/amazon-connect-chat-ui-examples/assets/1439784 5. Once everything looks okay, Run the app by clicking on ▶️ button `Control + R`or`^ + R`. -## How is it working? - -### ChatViewModel -It is responsible for managing the chat state, including initiating the chat, sending and receiving messages, and closing the chat connection when done. - -- **Managing Messages**: It holds an array of Message objects, which are published to the UI to reflect real-time chat updates. -- **Handling WebSockets**: ChatViewModel integrates with WebsocketManager to manage WebSocket connections for real-time message delivery. - -#### Initialization: -Upon instantiation, ChatViewModel sets up necessary configurations and prepares the AWS Connect Participant client. -- **Chat Initiation**: - When initiateChat is called, it ensures that a WebSocket URL is available, then creates a WebSocket manager instance that listens for incoming messages and events. -- **Message Handling**: - onMessageReceived processes incoming messages and updates the UI accordingly. It filters out typing indicators and handles message status updates (e.g., delivered, read). -- **Chat API Calls**: - The view model interacts with ChatRepository to API calls to start chat sessions (startChatContact), create participant connections (createParticipantConnection), and send messages or events (sendChatMessage, sendEvent). - -### ChatRepository -- **Interfacing with AWS Services**: Makes HTTP calls and Utilizes the AWS Connect Participant Service to register a participant and establish a chat session. - -### WebsocketManager: -The WebsocketManager handles the WebSocket connection lifecycle and receives chat messages and other events. - -- **WebSocket Connection**: - Manages the WebSocket connection, handling connect and disconnect events, and transmitting chat messages. -- **Receiving Messages**: - Implements the didReceive method to handle different WebSocket events, such as incoming text messages that are then passed to the messageCallback. -- **Connection**: - Connects to the WebSocket using the provided URL and listens for events. -- **Event Handling**: - On receiving events, it delegates processing to the appropriate handlers, for instance: - Messages are processed and passed to ChatViewModel through the messageCallback. - Connection status changes are logged, and isConnected status is updated. -- **Message Distribution**: - Incoming text messages are deserialized and depending on their type (MESSAGE, EVENT, etc.), appropriate actions are taken, such as updating UI or acknowledging message receipt. -- **websocketDidConnect**: Called when the WebSocket connects, and may subscribe to topics if necessary. -- **websocketDidReceiveMessage**: Parses and handles incoming messages, delegating them back to ChatManager for UI updates. - - -### Chat Rehydration - -Chat rehydration is a feature that allows users to continue their previous chat sessions. This process involves several checks and actions: - -- **Check for Participant Token:** - On initiating chat, the module first checks if a `participantToken` exists. - - If it exists, the module proceeds to fetch the chat transcript, allowing the user to continue from where they left off. - - If it does not exist, the module then checks for a `contactId`. - -- **Use of Contact ID:** - If a `contactId` exists, the module prompts the user to either restore the previous session or start a new chat. - - If the user chooses to restore, the module starts a new chat session with the existing `contactId`, creates a new participant connection, and then fetches the transcript. - - If the user opts for a new chat, the module deletes the stored `contactId` and `participantToken` from the storage, ensuring a fresh start. The chat begins with no prior context, emulating the start of a new conversation. - - > There will be a new `initialContactId` when chat is rehydrated. The existing `contactId` will only be used as the `sourceContactId`. You may need to use the new `initialContactId` for the `CreateParticipantConnection` call or other APIs if you want to operate on the new contact. - -- **Deleting Stored Values:** - For users who opt to start a new chat, the module ensures that previous session identifiers are cleared. This action prevents any overlap or confusion between different chat sessions. By removing the `participantToken` and `contactId`, the ChatViewModel guarantees that the new chat session does not carry over any data or context from previous sessions. - -```mermaid -flowchart TD - style D fill:#fc6b03 size:10 - A[Start Chat] --> B{Check for participantToken} - B -->|Exists| J[Fetch Chat Transcript] - B -->|Does not exist| D{Check for contactId} - D -->|Exists| E{User Choice} - E -->|Restore| F[Use existing contactId] - E -->|New Chat| G[Delete stored contactId and participantToken] - F --> H[Create new participant connection] - G --> I[Start fresh chat session] - H --> J[Fetch Chat Transcript] - I --> J - J --> K[Continue where left off] -``` - -Sample demo: - -https://github.com/amazon-connect/amazon-connect-chat-ui-examples/assets/143978428/ae078271-2699-4bae-b04a-503a3ac1bfdd +## Implementation + +The first step is to call the `StartChatContact` API and pass the response details into the SDK’s `ChatSession` object. + +```kotlin +// Start a new chat session by sending a StartChatRequest to the repository +private fun startChat() { + viewModelScope.launch { + val participantDetails = ParticipantDetails(displayName = chatConfiguration.customerName) + val request = StartChatRequest( + connectInstanceId = chatConfiguration.connectInstanceId, + contactFlowId = chatConfiguration.contactFlowId, + participantDetails = participantDetails + ) + when (val response = chatRepository.startChat(endpoint = chatConfiguration.startChatEndpoint,startChatRequest = request)) { + is Resource.Success -> { + response.data?.data?.startChatResult?.let { result -> + // handleStartChatResponse(result) + } + } + is Resource.Error -> { + // Log error + } + + is Resource.Loading -> // Still loading action + } + } +} +``` + +### Configuring and Using `ChatSession` in Your Project + +The majority of the SDKs functionality will be accessed through the `ChatSession` object. In order to use this object in the file, you can inject it using `@HiltViewModel`: + +``` +class ChatViewModel @Inject constructor( + private val chatSession: ChatSession, // Injected ChatSession + private val chatRepository: ChatRepository, + private val sharedPreferences: SharedPreferences, +) : ViewModel() { +``` + +If you are not using Hilt, then you can initialize `ChatSession` like this: + +``` +private val chatSession = ChatSessionProvider.getChatSession(context) +``` + +In this example, we are using a `ChatViewModel` class that helps bridge UI and SDK communication. This class is responsible for managing interactions with the SDK's ChatSession object. From here, we can access the SDK's suite of APIs from the `chatSession` property. + +Before using the chatSession object, we need to set the config for it via the GlobalConfig object. Most importantly, the GlobalConfig object will be used to set the AWS region that your Connect instance lives in. Here is an example of how to configure the ChatSession object: + +``` +private suspend fun configureChatSession() { + val globalConfig = GlobalConfig(region = chatConfiguration.region) + chatSession.configure(globalConfig) + ... + } +``` + +Once configured, we can pass the response of `StartChatAPI` into `chatSession` object and initiate connection. +```kotlin +// Handle the response after starting a chat session +private fun handleStartChatResponse(result: StartChatResponse.Data.StartChatResult) { + viewModelScope.launch { + val chatDetails = ChatDetails( + contactId = result.contactId, + participantId = result.participantId, + participantToken = result.participantToken + ) + createParticipantConnection(chatDetails) + } +} +``` + +## Interacting with Amazon Connect Chat SDK + +From here, you are now ready to interact with the chat via the `ChatSession` object. For more information, please refer to [ Amazon Connect Chat SDK ](https://github.com/amazon-connect/amazon-connect-chat-android). + +#### Create Conenection +```kotlin + +// Create a connection to the participant chat session +private fun createParticipantConnection(chatDetails: ChatDetails) { + viewModelScope.launch { + val result = chatSession.connect(chatDetails) // Attempt connection + + if (result.isSuccess) { + Log.d("ChatViewModel", "Connection successful $result") + } else if (result.isFailure) { + Log.e("ChatViewModel", "Connection failed: ${result.exceptionOrNull()}") + } + } +} +``` + + +#### SendMessage +```kotlin +fun sendMessage(text: String) { + viewModelScope.launch { + if (text.isNotEmpty()) { + val result = chatSession.sendMessage(ContentType.RICH_TEXT, text) + result.onSuccess { + // Handle success - update UI or state as needed + }.onFailure { exception -> + // Handle failure - update UI or state, log error, etc. + Log.e("ChatViewModel", "Error sending message: ${exception.message}") + } + } + } +} +``` + + +#### How to receive messages +``` +chatSession.onMessageReceived = { transcriptItem -> + // Handle received websocket message if needed +} + +chatSession.onTranscriptUpdated = { transcriptList -> + Log.d("ChatViewModel", "Transcript onTranscriptUpdated last 3 items: ${transcriptList.takeLast(3)}") + viewModelScope.launch { + onUpdateTranscript(transcriptList) + } +} +``` + +#### SendEvent +```kotlin +fun sendEvent(content: String = "", contentType: ContentType) { + viewModelScope.launch { + val result = chatSession.sendEvent(contentType, content) + result.onSuccess { + // Handle success - update UI or state as needed + }.onFailure { exception -> + // Handle failure - update UI or state, log error, etc. + Log.e("ChatViewModel", "Error sending event: ${exception.message}") + } + } +} +``` +#### GetTranscript +```kotlin +fun fetchTranscript(onCompletion: (Boolean) -> Unit) { + viewModelScope.launch { + chatSession.getTranscript(ScanDirection.BACKWARD, SortKey.DESCENDING, 30, null, messages?.get(0)?.id).onSuccess { + Log.d("ChatViewModel", "Transcript fetched successfully") + onCompletion(true) + }.onFailure { + Log.e("ChatViewModel", "Error fetching transcript: ${it.message}") + onCompletion(false) + } + } +} +``` +#### Disconnect +```kotlin +fun endChat() { + clearParticipantToken() + viewModelScope.launch { + chatSession.disconnect() // Disconnect from chat session + } +} +``` + +#### Setting Up Chat Event Handlers +The ChatSession object also exposes handlers for common chat events for users to build on. Here is an example code block that demonstrates how you can register event handlers to chat events. + +```kotlin +private suspend fun setupChatHandlers(chatSession: ChatSession) { + chatSession.onConnectionEstablished = { + Log.d("ChatViewModel", "Connection established.") + _isChatActive.value = true + } + + chatSession.onMessageReceived = { transcriptItem -> + // Handle received websocket message if needed + } + + chatSession.onTranscriptUpdated = { transcriptList -> + Log.d("ChatViewModel", "Transcript onTranscriptUpdated last 3 items: ${transcriptList.takeLast(3)}") + viewModelScope.launch { + onUpdateTranscript(transcriptList) + } + } + + chatSession.onChatEnded = { + Log.d("ChatViewModel", "Chat ended.") + _isChatActive.value = false + } + + chatSession.onConnectionBroken = { + Log.d("ChatViewModel", "Connection broken.") + } + + chatSession.onConnectionReEstablished = { + Log.d("ChatViewModel", "Connection re-established.") + _isChatActive.value = true + } + + chatSession.onChatSessionStateChanged = { + Log.d("ChatViewModel", "Chat session state changed: $it") + _isChatActive.value = it + } + + chatSession.onDeepHeartBeatFailure = { + Log.d("ChatViewModel", "Deep heartbeat failure") + } +} +``` ## Specifications diff --git a/mobileChatExamples/androidChatExample/app/.gitignore b/mobileChatExamples/androidChatExample/app/.gitignore index 42afabf..aa724b7 100644 --- a/mobileChatExamples/androidChatExample/app/.gitignore +++ b/mobileChatExamples/androidChatExample/app/.gitignore @@ -1 +1,15 @@ -/build \ No newline at end of file +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/mobileChatExamples/androidChatExample/app/build.gradle.kts b/mobileChatExamples/androidChatExample/app/build.gradle.kts index 0343045..3e6f091 100644 --- a/mobileChatExamples/androidChatExample/app/build.gradle.kts +++ b/mobileChatExamples/androidChatExample/app/build.gradle.kts @@ -8,11 +8,11 @@ plugins { } android { - namespace = "com.blitz.androidchatexample" + namespace = "com.amazon.connect.chat.androidchatexample" compileSdk = 34 defaultConfig { - applicationId = "com.blitz.androidchatexample" + applicationId = "com.amazon.connect.chat.androidchatexample" minSdk = 24 targetSdk = 34 versionCode = 1 @@ -58,43 +58,44 @@ android { dependencies { - implementation("androidx.core:core-ktx:1.12.0") - implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") - implementation("androidx.activity:activity-compose:1.8.0") - implementation(platform("androidx.compose:compose-bom:2023.03.00")) + implementation("androidx.core:core-ktx:1.13.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6") + implementation("androidx.activity:activity-compose:1.9.3") + implementation(platform("androidx.compose:compose-bom:2024.10.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") - implementation("androidx.compose.material3:material3:1.1.2") - implementation("com.google.android.gms:play-services-basement:18.2.0") + implementation("androidx.compose.material3:material3:1.3.0") + implementation("androidx.compose.material:material-icons-extended:1.7.0") + implementation("com.google.android.gms:play-services-basement:18.4.0") testImplementation("junit:junit:4.13.2") - androidTestImplementation("androidx.test.ext:junit:1.1.5") - androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") - androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00")) + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2024.10.00")) androidTestImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") //lifecycle livedata implementation("androidx.compose.runtime:runtime-livedata") - implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2") - implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2") - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.8.6") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") //Retrofit - implementation("com.squareup.retrofit2:retrofit:2.9.0") + implementation("com.squareup.retrofit2:retrofit:2.11.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0") - implementation("com.squareup.okhttp3:okhttp:4.10.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:logging-interceptor:4.9.1") implementation("com.squareup:otto:1.3.8") implementation("com.squareup.retrofit2:adapter-rxjava2:2.3.0") //Hilt - implementation("com.google.dagger:hilt-android:2.48.1") - implementation("androidx.hilt:hilt-navigation-compose:1.1.0") - kapt("androidx.hilt:hilt-compiler:1.1.0") - implementation("androidx.navigation:navigation-compose:2.7.5") - kapt("com.google.dagger:hilt-android-compiler:2.48.1") + implementation("com.google.dagger:hilt-android:2.49") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + kapt("androidx.hilt:hilt-compiler:1.2.0") + implementation("androidx.navigation:navigation-compose:2.8.3") + kapt("com.google.dagger:hilt-android-compiler:2.49") //AWS : https://github.com/aws-amplify/aws-sdk-android implementation("com.amazonaws:aws-android-sdk-core:2.73.0") @@ -106,6 +107,13 @@ dependencies { // Image loading implementation("io.coil-kt:coil-compose:2.5.0") + // Pull to refresh + implementation("com.google.accompanist:accompanist-swiperefresh:0.32.0") + + //Amazon Connect Chat SDK https://github.com/amazon-connect/amazon-connect-chat-android/ + implementation("software.aws.connect:amazon-connect-chat-android:0.0.1-alpha") + + } ruler { @@ -113,4 +121,5 @@ ruler { locale.set("en") screenDensity.set(480) sdkVersion.set(33) -} \ No newline at end of file +} + diff --git a/mobileChatExamples/androidChatExample/app/src/androidTest/java/com/blitz/androidchatexample/ExampleInstrumentedTest.kt b/mobileChatExamples/androidChatExample/app/src/androidTest/java/com/amazon/connect/chat/androidchatexample/ExampleInstrumentedTest.kt similarity index 80% rename from mobileChatExamples/androidChatExample/app/src/androidTest/java/com/blitz/androidchatexample/ExampleInstrumentedTest.kt rename to mobileChatExamples/androidChatExample/app/src/androidTest/java/com/amazon/connect/chat/androidchatexample/ExampleInstrumentedTest.kt index 8137971..308ee6a 100644 --- a/mobileChatExamples/androidChatExample/app/src/androidTest/java/com/blitz/androidchatexample/ExampleInstrumentedTest.kt +++ b/mobileChatExamples/androidChatExample/app/src/androidTest/java/com/amazon/connect/chat/androidchatexample/ExampleInstrumentedTest.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample +package com.amazon.connect.chat.androidchatexample import androidx.test.platform.app.InstrumentationRegistry import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -19,6 +19,6 @@ class ExampleInstrumentedTest { fun useAppContext() { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("com.blitz.androidchatexample", appContext.packageName) + assertEquals("com.amazon.connect.chat.androidchatexample", appContext.packageName) } } \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/AndroidManifest.xml b/mobileChatExamples/androidChatExample/app/src/main/AndroidManifest.xml index fcc74f8..5a8c3a7 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/AndroidManifest.xml +++ b/mobileChatExamples/androidChatExample/app/src/main/AndroidManifest.xml @@ -1,17 +1,21 @@ + + + android:theme="@style/Theme.androidconnectchatandroid"> + + + + \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ChatApplication.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ChatApplication.kt similarity index 79% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ChatApplication.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ChatApplication.kt index 167fc21..a09322a 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ChatApplication.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ChatApplication.kt @@ -1,11 +1,11 @@ -package com.blitz.androidchatexample - -import android.app.Application -import dagger.hilt.android.HiltAndroidApp - -@HiltAndroidApp -class ChatApplication: Application() { - override fun onCreate() { - super.onCreate() - } +package com.amazon.connect.chat.androidchatexample + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class ChatApplication: Application() { + override fun onCreate() { + super.onCreate() + } } \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/Config.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/Config.kt similarity index 60% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/Config.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/Config.kt index 1c81934..0fdfd93 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/Config.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/Config.kt @@ -1,14 +1,12 @@ -package com.blitz.androidchatexample +package com.amazon.connect.chat.androidchatexample import com.amazonaws.regions.Regions object Config { val connectInstanceId: String = "" val contactFlowId: String = "" - val startChatEndpoint: String = "" + val startChatEndpoint: String = "https://.execute-api..amazonaws.com/Prod/" val region: Regions = Regions.US_WEST_2 val agentName = "AGENT" val customerName = "CUSTOMER" - - -} \ No newline at end of file +} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/MainActivity.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/MainActivity.kt new file mode 100644 index 0000000..56efe16 --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/MainActivity.kt @@ -0,0 +1,458 @@ +package com.amazon.connect.chat.androidchatexample + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModelProvider +import com.amazon.connect.chat.androidchatexample.ui.theme.androidconnectchatandroidTheme +import com.amazon.connect.chat.androidchatexample.utils.CustomLogger +import com.amazon.connect.chat.androidchatexample.utils.FileUtils.getOriginalFileName +import com.amazon.connect.chat.androidchatexample.utils.FileUtils.previewFileFromCacheOrDownload +import com.amazon.connect.chat.androidchatexample.viewmodel.ChatViewModel +import com.amazon.connect.chat.androidchatexample.views.AttachmentTextView +import com.amazon.connect.chat.androidchatexample.views.ChatMessageView +import com.amazon.connect.chat.sdk.model.ContentType +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.utils.CommonUtils.Companion.keyboardAsState +import com.amazon.connect.chat.sdk.utils.logger.SDKLogger +import dagger.hilt.android.AndroidEntryPoint +import java.net.URL + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private lateinit var chatViewModel: ChatViewModel + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Disable screenshots + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + + chatViewModel = ViewModelProvider(this)[ChatViewModel::class.java] + val externalFileDir = applicationContext.getExternalFilesDir(null) + + if (externalFileDir !== null) { + val logger = CustomLogger() + logger.setLogOutputDir(externalFileDir) + SDKLogger.configureLogger(logger) + } + + setContent { + androidconnectchatandroidTheme { + // A surface container using the 'background' color from the theme + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + ChatScreen(this) + } + } + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == 2 && resultCode == Activity.RESULT_OK) { + data?.data?.let { fileUri -> + chatViewModel.selectedFileUri.value = fileUri + } + } + } +} + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen(activity: Activity, viewModel: ChatViewModel = hiltViewModel()) { + var showCustomSheet by remember { mutableStateOf(false) } + val isLoading = viewModel.isLoading.observeAsState(initial = false) + val isChatActive = viewModel.isChatActive.observeAsState(initial = false) + var showDialog by remember { mutableStateOf(false) } + var showRestoreDialog by remember { mutableStateOf(false) } + val participantToken = viewModel.liveParticipantToken.observeAsState() + var showErrorDialog by remember { mutableStateOf(false) } + val errorMessage by viewModel.errorMessage.observeAsState() + + LaunchedEffect(errorMessage) { + showErrorDialog = errorMessage != null + } + + if (showErrorDialog) { + AlertDialog( + onDismissRequest = { + showErrorDialog = false + viewModel.clearErrorMessage() + }, + title = { Text("Error") }, + text = { Text(errorMessage ?: "An unknown error occurred") }, + confirmButton = { + TextButton(onClick = { + showErrorDialog = false + viewModel.clearErrorMessage() + }) { Text("OK") } + } + ) + } + + if (showRestoreDialog) { + AlertDialog( + onDismissRequest = { showRestoreDialog = false }, + title = { Text("Restore Chat") }, + text = { Text("Do you want to restore the previous chat session?") }, + confirmButton = { + TextButton( + onClick = { + showRestoreDialog = false + viewModel.clearParticipantToken() + viewModel.initiateChat() // Restore the chat directly + } + ) { Text("Restore") } + }, + dismissButton = { + TextButton(onClick = { + showRestoreDialog = false + viewModel.clearParticipantToken() + viewModel.initiateChat() // Start new chat + }) { Text("Start new") } + } + ) + } + + if (showDialog) { + AlertDialog( + onDismissRequest = { showDialog = false }, + title = { Text("End Chat") }, + text = { Text("Are you sure you want to end the chat?") }, + confirmButton = { + TextButton( + onClick = { + showDialog = false + viewModel.endChat() + showCustomSheet = false + } + ) { Text("Yes") } + }, + dismissButton = { + TextButton(onClick = { showDialog = false }) { Text("Cancel") } + } + ) + } + + Scaffold( + floatingActionButton = { + if (!showCustomSheet) { + ExtendedFloatingActionButton( + text = { + if (isChatActive.value == false) { + Text("Start Chat") + } else { + Text("Resume Chat") + } + }, + icon = { + if (isLoading.value) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + } + }, + onClick = { + if (isChatActive.value == false) { + viewModel.initiateChat() + } else { + showCustomSheet = true + } + }, + + ) + } + } + ) { + LaunchedEffect(isChatActive.value) { + if (!isLoading.value && isChatActive.value) { + showCustomSheet = true + } + } + + + Column { + ParticipantTokenSection(activity, viewModel) + Spacer(modifier = Modifier.height(16.dp)) + } +// ParticipantTokenSection(activity, viewModel) + + AnimatedVisibility( + visible = showCustomSheet, + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + + Box( + modifier = Modifier + .fillMaxSize() + .imePadding() + .background(Color.White, RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) + ) { + Column { + TopAppBar( + title = { + Text( + "Chat", modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally) + ) + }, + navigationIcon = { + IconButton(onClick = { showCustomSheet = false }) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton(onClick = { + showDialog = true + }) { + Text("End Chat", color = Color.Red) + } + } + ) + ChatView(activity = activity, viewModel = viewModel) // Your chat view composable + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatView(viewModel: ChatViewModel, activity: Activity) { + val messages = viewModel.messages + var textInput by remember { mutableStateOf("") } + val listState = rememberLazyListState() + var isKeyboardVisible = keyboardAsState().value + var isChatEnded by remember { mutableStateOf(false) } + // Track if the typing event has been sent + var hasSentTypingEvent by remember { mutableStateOf(false) } + var isRefreshing by remember { mutableStateOf(false) } + val state = rememberPullToRefreshState() + val coroutineScope = rememberCoroutineScope() + var recentOutgoingMessageID by remember { mutableStateOf(null) } + + val onPreviewAttachment: (URL, String) -> Unit = { uri, fileName-> + previewFileFromCacheOrDownload(activity, uri, fileName) + } + + val selectedFileName by viewModel.selectedFileUri.observeAsState() + + val onRefresh: () -> Unit = { + isRefreshing = true + viewModel.fetchTranscript { success -> + isRefreshing = false + if (success) { + Log.d("ChatView", "Transcript fetched successfully") + } else { + Log.e("ChatView", "Failed to fetch transcript") + } + } + } + + // Scroll to the last message when messages change + LaunchedEffect(messages.lastOrNull()?.hashCode()) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(messages.lastIndex) + } + + // Find last outgoing message and set recentOutgoingMessageID + recentOutgoingMessageID = messages.lastOrNull { + it is Message && + it.messageDirection == MessageDirection.OUTGOING + }?.id + } + + LaunchedEffect(isKeyboardVisible) { + // Send typing event only once when the keyboard is visible and there's input + if (isKeyboardVisible && !hasSentTypingEvent) { + Log.d("ChatView", "Sending typing event") + viewModel.sendEvent(contentType = ContentType.TYPING) + hasSentTypingEvent = true + } + + // Reset the flag when the keyboard is hidden + if (!isKeyboardVisible) { + hasSentTypingEvent = false + } + } + + PullToRefreshBox( + modifier = Modifier + .fillMaxSize() + .imePadding(), + state = state, + isRefreshing = isRefreshing, + onRefresh = onRefresh + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(start = 8.dp, end = 8.dp) + ) { + // Display the chat messages + LazyColumn(state = listState, modifier = Modifier.weight(1f)) { + itemsIndexed(messages, key = { index, message -> message.id }) { index, message -> + ChatMessage( + transcriptItem = message, + viewModel = viewModel, + onPreviewAttachment = onPreviewAttachment, + recentOutgoingMessageID = recentOutgoingMessageID + ) + LaunchedEffect(key1 = message, key2 = index) { + if (message.contentType == ContentType.ENDED.type) { + isChatEnded = true + viewModel.clearParticipantToken() + } else { + isChatEnded = false + } + // Logic to determine if the message is visible. + // For simplicity, let's say it's visible if it's one of the last three messages. + if (index == messages.size - 1 && message is Message) { + viewModel.sendReadEventOnAppear(message) + } + } + } + } + Row( + modifier = Modifier + .padding(8.dp) + .padding(bottom = 8.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.Bottom + ) { + AttachmentTextView( + text = textInput, + selectedFileUri = selectedFileName?.getOriginalFileName(activity), + onTextChange = { text -> + textInput = text + }, + onRemoveAttachment = { + viewModel.selectedFileUri.value = null + }, + modifier = Modifier.weight(1f) + ) + + IconButton( + onClick = { + if(!selectedFileName?.lastPathSegment.isNullOrEmpty()) { + selectedFileName?.let { viewModel.uploadAttachment(it) } + } + if (textInput.trim().isNotEmpty()) { + viewModel.sendMessage(textInput) + } + textInput = "" + viewModel.selectedFileUri.value = null + }, + enabled = !isChatEnded, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send") + } + + IconButton( + onClick = { + viewModel.openFile(activity = activity ) + }, + enabled = !isChatEnded, + modifier = Modifier.size(48.dp) + ) { + Icon(Icons.Default.AttachFile, contentDescription = "Attach") + } + } + + } + } +} + +@Composable +fun ChatMessage( + transcriptItem: TranscriptItem, + viewModel: ChatViewModel, + recentOutgoingMessageID: String?, + onPreviewAttachment: (URL, String) -> Unit +) { + ChatMessageView(transcriptItem = transcriptItem, viewModel = viewModel, onPreviewAttachment = onPreviewAttachment, recentOutgoingMessageID = recentOutgoingMessageID) +} + +@Composable +fun ParticipantTokenSection(activity: Activity, viewModel: ChatViewModel) { + val participantToken by viewModel.liveParticipantToken.observeAsState() + + Column(modifier = Modifier.padding(16.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + 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") + } + } +} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/di/AppModule.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/di/AppModule.kt similarity index 91% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/di/AppModule.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/di/AppModule.kt index 1154dc2..ab6eeb5 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/di/AppModule.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/di/AppModule.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.di +package com.amazon.connect.chat.androidchatexample.di import android.content.Context import android.content.SharedPreferences @@ -18,4 +18,5 @@ object AppModule { fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences { return context.getSharedPreferences("ConnectChat", Context.MODE_PRIVATE) } + } diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/StartChatRequest.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatRequest.kt similarity index 64% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/StartChatRequest.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatRequest.kt index 49350ea..152cc28 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/StartChatRequest.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatRequest.kt @@ -1,11 +1,10 @@ -package com.blitz.androidchatexample.models +package com.amazon.connect.chat.androidchatexample.models 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/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/StartChatResponse.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatResponse.kt similarity index 89% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/StartChatResponse.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatResponse.kt index dd0fc8d..c70ec06 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/StartChatResponse.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/models/StartChatResponse.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.models +package com.amazon.connect.chat.androidchatexample.models import com.google.gson.annotations.SerializedName diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/ApiInterface.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/ApiInterface.kt new file mode 100644 index 0000000..233ce27 --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/ApiInterface.kt @@ -0,0 +1,14 @@ +package com.amazon.connect.chat.androidchatexample.network + +import com.amazon.connect.chat.androidchatexample.models.StartChatRequest +import com.amazon.connect.chat.androidchatexample.models.StartChatResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Url +import javax.inject.Singleton + +@Singleton +interface ApiInterface { + @POST() + suspend fun startChat(@Url url: String, @Body request: StartChatRequest): StartChatResponse +} \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/ApiService.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/ApiService.kt similarity index 81% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/ApiService.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/ApiService.kt index 851ef23..8b94029 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/ApiService.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/ApiService.kt @@ -1,59 +1,47 @@ -package com.blitz.androidchatexample.network - -import com.blitz.androidchatexample.Config -import com.blitz.androidchatexample.repository.ChatRepository -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.util.concurrent.TimeUnit -import javax.inject.Singleton - -@InstallIn(SingletonComponent::class) -@Module -class ApiService { - - @Singleton - @Provides - fun provideUserRepository( - api: ApiInterface - ) = ChatRepository(api) - - @Singleton - @Provides - fun providesUserApi(): ApiInterface { - val chatConfiguration= Config - var okHttpClient: OkHttpClient? = null - val httpLoggingInterceptor = HttpLoggingInterceptor() - httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY) - - okHttpClient = OkHttpClient.Builder() - .addInterceptor(httpLoggingInterceptor) - .readTimeout(60, TimeUnit.SECONDS) - .connectTimeout(60, TimeUnit.SECONDS) - .build() - - return Retrofit.Builder() - .baseUrl(chatConfiguration.startChatEndpoint) - .addConverterFactory(GsonConverterFactory.create()) - .client(okHttpClient) - .build() - .create(ApiInterface::class.java) - } -} - - - - - - - - - - - - +package com.amazon.connect.chat.androidchatexample.network + +import com.amazon.connect.chat.androidchatexample.Config +import com.amazon.connect.chat.androidchatexample.repository.ChatRepository +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@InstallIn(SingletonComponent::class) +@Module +class ApiService { + + @Singleton + @Provides + fun provideUserRepository( + api: ApiInterface + ) = ChatRepository(api) + + @Singleton + @Provides + fun providesUserApi(): ApiInterface { + val chatConfiguration= Config + var okHttpClient: OkHttpClient? = null + val httpLoggingInterceptor = HttpLoggingInterceptor() + httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY) + + okHttpClient = OkHttpClient.Builder() + .addInterceptor(httpLoggingInterceptor) + .readTimeout(60, TimeUnit.SECONDS) + .connectTimeout(60, TimeUnit.SECONDS) + .build() + + return Retrofit.Builder() + .baseUrl("https://www.example.com/") // Placeholder URL + .addConverterFactory(GsonConverterFactory.create()) + .client(okHttpClient) + .build() + .create(ApiInterface::class.java) + } +} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/Resource.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/Resource.kt similarity index 81% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/Resource.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/Resource.kt index 738bdf2..63bcc64 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/Resource.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/network/Resource.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.network +package com.amazon.connect.chat.androidchatexample.network sealed class Resource(val data: T? = null, val message: String? = null) { class Success(data: T): Resource(data) diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/repository/ChatRepository.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/repository/ChatRepository.kt new file mode 100644 index 0000000..ac2a4fc --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/repository/ChatRepository.kt @@ -0,0 +1,41 @@ +package com.amazon.connect.chat.androidchatexample.repository + +import com.amazon.connect.chat.androidchatexample.models.StartChatRequest +import com.amazon.connect.chat.androidchatexample.models.StartChatResponse +import com.amazon.connect.chat.androidchatexample.network.ApiInterface +import com.amazon.connect.chat.androidchatexample.network.Resource +import dagger.hilt.android.scopes.ActivityScoped +import org.json.JSONObject +import retrofit2.HttpException +import javax.inject.Inject + +@ActivityScoped +class ChatRepository @Inject constructor( + private val apiInterface: ApiInterface +) { + + // StartChat API: https://docs.aws.amazon.com/connect/latest/APIReference/API_StartChatContact.html + // Android SDK Docs: https://github.com/aws-amplify/aws-sdk-android + suspend fun startChat(startChatRequest: StartChatRequest, endpoint: String): Resource { + return try { + val response = apiInterface.startChat(endpoint,startChatRequest) + Resource.Success(response) + } catch (e: HttpException) { + val errorBody = e.response()?.errorBody()?.string() + val errorMessage = parseErrorMessageFromJson(errorBody) + Resource.Error(errorMessage ?: "Unknown error occurred") + } catch (e: Exception) { + Resource.Error("An unknown error occurred: ${e.localizedMessage}") + } + } + + private fun parseErrorMessageFromJson(jsonString: String?): String? { + return try { + JSONObject(jsonString).getJSONObject("data").getJSONObject("Error").getString("message") + } catch (e: Exception) { + null // Return null if there is an issue parsing the JSON + } + } + + +} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Color.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Color.kt similarity index 80% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Color.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Color.kt index 8b6e81f..cfa1d0c 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Color.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Color.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.ui.theme +package com.amazon.connect.chat.androidchatexample.ui.theme import androidx.compose.ui.graphics.Color diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Theme.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Theme.kt similarity index 79% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Theme.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Theme.kt index 3f455be..c39a758 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Theme.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Theme.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.ui.theme +package com.amazon.connect.chat.androidchatexample.ui.theme import android.app.Activity import android.os.Build @@ -38,21 +38,13 @@ private val LightColorScheme = lightColorScheme( ) @Composable -fun AndroidChatExampleTheme( - darkTheme: Boolean = isSystemInDarkTheme(), +fun androidconnectchatandroidTheme( + darkTheme: Boolean = false, // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } + val colorScheme = LightColorScheme val view = LocalView.current if (!view.isInEditMode) { SideEffect { diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Type.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Type.kt similarity index 89% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Type.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Type.kt index 1e447ec..891a41e 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/ui/theme/Type.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/ui/theme/Type.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.ui.theme +package com.amazon.connect.chat.androidchatexample.ui.theme import androidx.compose.material3.Typography import androidx.compose.ui.text.TextStyle diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CommonUtils.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CommonUtils.kt new file mode 100644 index 0000000..eaa172a --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CommonUtils.kt @@ -0,0 +1,111 @@ +package com.amazon.connect.chat.androidchatexample.utils + +import com.amazon.connect.chat.sdk.model.ContentType +import com.amazon.connect.chat.sdk.model.Event +import com.amazon.connect.chat.sdk.model.Message +import com.amazon.connect.chat.sdk.model.MessageDirection +import com.amazon.connect.chat.sdk.model.MessageStatus +import com.amazon.connect.chat.sdk.model.TranscriptItem +import java.text.SimpleDateFormat +import java.util.Date +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 formatDate(currentTimeMillis: Long, forLogs: Boolean = false): String { + val date = Date(currentTimeMillis) + var utcFormatter: SimpleDateFormat? = null + if (forLogs) { + utcFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } else { + utcFormatter = SimpleDateFormat("yyyy-MM-dd_HH-mm", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + } + return utcFormatter.format(date) + } + + fun getMessageDirection(transcriptItem: TranscriptItem) { + when (transcriptItem) { + is Message -> { + val participant = transcriptItem.participant?.lowercase() + val direction = when (participant) { + "customer" -> MessageDirection.OUTGOING + "agent", "system" -> MessageDirection.INCOMING + else -> MessageDirection.COMMON + } + transcriptItem.messageDirection = direction + } + is Event -> { + val participant = transcriptItem.participant?.lowercase() ?: return + val direction = if (transcriptItem.contentType == ContentType.TYPING.type) { + when (participant) { + "customer" -> MessageDirection.OUTGOING + else -> MessageDirection.INCOMING + } + } else { + MessageDirection.COMMON + } + transcriptItem.eventDirection = direction + } + } + } + + fun customizeEvent(event: Event) { + val displayNameOrParticipant = if (!event.displayName.isNullOrEmpty()) { + event.displayName + } else { + event.participant + } ?: "SYSTEM" + + when (event.contentType) { + ContentType.JOINED.type -> { + event.text = "$displayNameOrParticipant has joined the chat" + event.participant = "System" + } + ContentType.LEFT.type -> { + event.text = "$displayNameOrParticipant has left the chat" + event.participant = "System" + } + ContentType.ENDED.type -> { + event.text = "The chat has ended" + event.participant = "System" + } + else -> { + // No customization needed for other content types + } + } + } + + + fun customMessageStatus(status: MessageStatus?): String { + return when (status) { + MessageStatus.Delivered -> "Delivered" + MessageStatus.Read -> "Read" + 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 + } + } +} \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CustomLogger.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CustomLogger.kt new file mode 100644 index 0000000..5eca7bd --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/CustomLogger.kt @@ -0,0 +1,97 @@ +package com.amazon.connect.chat.androidchatexample.utils + +import com.amazon.connect.chat.sdk.utils.logger.ChatSDKLogger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileNotFoundException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.io.path.Path +import kotlin.io.path.appendText +import kotlin.io.path.createFile +import kotlin.io.path.exists + +class CustomLogger : ChatSDKLogger { + private var outputFileDir: File? = null + private val job = SupervisorJob() + private val coroutineScope = CoroutineScope(job + Dispatchers.IO) + + private val currentTimeMillis = System.currentTimeMillis() + private val loggerCreationDateAndTime = CommonUtils.formatDate(currentTimeMillis, false) + + override fun logVerbose(message: () -> String) { + // Custom logging logic + val logMessage = "VERBOSE: ${message()}" + println(logMessage) + coroutineScope.launch { + writeToAppTempFile(logMessage) + } + } + + override fun logInfo(message: () -> String) { + // Custom logging logic + val logMessage = "INFO: ${message()}" + println(logMessage) + coroutineScope.launch { + writeToAppTempFile(logMessage) + } + } + + override fun logDebug(message: () -> String) { + // Custom logging logic + val logMessage = "DEBUG: ${message()}" + println(logMessage) + coroutineScope.launch { + writeToAppTempFile(logMessage) + } + + } + + override fun logWarn(message: () -> String) { + // Custom logging logic + val logMessage = "WARN: ${message()}" + println(logMessage) + coroutineScope.launch { + writeToAppTempFile(logMessage) + } + } + + override fun logError(message: () -> String) { + // Custom logging logic + val logMessage = "ERROR: ${message()}" + println(logMessage) + coroutineScope.launch { + writeToAppTempFile(logMessage) + } + } + + fun setLogOutputDir(tempFile: File) { + outputFileDir = tempFile + } + + private suspend fun writeToAppTempFile(content: String): Result { + return withContext(Dispatchers.IO) { + runCatching { + val currentTimeMillis = System.currentTimeMillis() + val formattedDateTimeForLogs = CommonUtils.formatDate(currentTimeMillis, true) + if (outputFileDir == null || !outputFileDir!!.exists() || !outputFileDir!!.isDirectory()) { + return@runCatching false + } + val filePath = Path(outputFileDir!!.absolutePath, "$loggerCreationDateAndTime-amazon-connect-logs.txt") + + if (!filePath.exists()) { + filePath.createFile() + } + + filePath.appendText("[$formattedDateTimeForLogs] $content \n") + true + } + } + } +} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/FileUtils.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/FileUtils.kt new file mode 100644 index 0000000..08a35a5 --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/utils/FileUtils.kt @@ -0,0 +1,95 @@ +package com.amazon.connect.chat.androidchatexample.utils + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import java.io.File +import java.io.IOException +import java.net.URL + +object FileUtils { + + fun Uri.getOriginalFileName(context: Context): String? { + return context.contentResolver.query(this, null, null, null, null)?.use { + val nameColumnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + it.moveToFirst() + it.getString(nameColumnIndex) + } + } + + private fun getMimeType(fileName: String): String { + val extension = fileName.substringAfterLast('.', "") + return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "*/*" + } + + fun previewFileFromCacheOrDownload( + context: Context, + fileUrl: URL, + fileName: String + ) { + val tempDir = File(context.cacheDir, "attachments") + if (!tempDir.exists()) { + tempDir.mkdirs() + } + + val file = File(tempDir, fileName) + + // Check if the file already exists in the cache + if (file.exists()) { + // File exists, use it for preview + val fileUri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + previewFile(context, fileUri, getMimeType(file.name)) + } else { + // File doesn't exist, download and then preview + downloadFileAndPreview(context, fileUrl, file) + } + } + + // Function to download the file and preview it + private fun downloadFileAndPreview(context: Context, fileUrl: URL, file: File) { + try { + val connection = fileUrl.openConnection() + connection.connect() + + file.outputStream().use { output -> + connection.getInputStream().use { input -> + input.copyTo(output) + } + } + + // Use FileProvider to get the URI for the downloaded file + val fileUri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + + // Preview the file using an intent + previewFile(context, fileUri, getMimeType(file.name)) + } catch (e: IOException) { + Log.e("AttachmentPreview", "Error downloading file: ${e.localizedMessage}") + } + } + + private fun previewFile(context: Context, fileUri: Uri, mimeType: String? = null) { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(fileUri, mimeType) // Adjust the MIME type based on the file type if known + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) // Grant temporary read permission + } + + try { + context.startActivity(intent) + } catch (e: Exception) { + Log.e("FilePreview", "No app found to open this file type: ${e.message}") + } + } + +} \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/viewmodel/ChatViewModel.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/viewmodel/ChatViewModel.kt new file mode 100644 index 0000000..e884c13 --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/viewmodel/ChatViewModel.kt @@ -0,0 +1,314 @@ +package com.amazon.connect.chat.androidchatexample.viewmodel + +import android.app.Activity +import android.content.Intent +import android.content.SharedPreferences +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.mutableStateListOf +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.amazon.connect.chat.androidchatexample.Config +import com.amazon.connect.chat.androidchatexample.models.ParticipantDetails +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.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.MessageReceiptType +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 kotlinx.coroutines.launch +import java.net.URL +import javax.inject.Inject + +@HiltViewModel +class ChatViewModel @Inject constructor( + private val chatSession: ChatSession, // Injected ChatSession instance + private val chatRepository: ChatRepository, // Chat repository for API calls + private val sharedPreferences: SharedPreferences // Shared preferences for storing participant token +) : ViewModel() { + + // If you are not using Hilt, you can initialize ChatSession like this + // private val chatSession = ChatSessionProvider.getChatSession(context) + + // Configuration instance for chat settings + private val chatConfiguration = Config + + // LiveData for tracking loading state + private val _isLoading = MutableLiveData(false) + val isLoading: MutableLiveData = _isLoading + + // LiveData for tracking chat session activity state + private val _isChatActive = MutableLiveData(false) + val isChatActive: MutableLiveData = _isChatActive + + // LiveData to hold the URI of the selected file for attachment + private val _selectedFileUri = MutableLiveData(Uri.EMPTY) + val selectedFileUri: MutableLiveData = _selectedFileUri + + // State to store chat transcript items (messages and events) + var messages = mutableStateListOf() + private set + + // LiveData for handling error messages + private val _errorMessage = MutableLiveData() + val errorMessage: LiveData = _errorMessage + + // LiveData to hold participant token from shared preferences + private val _liveParticipantToken = MutableLiveData(sharedPreferences.getString("participantToken", null)) + val liveParticipantToken: LiveData = _liveParticipantToken + + // Property to get or set participant token in shared preferences + private var participantToken: String? + get() = liveParticipantToken.value + set(value) { + sharedPreferences.edit().putString("participantToken", value).apply() + _liveParticipantToken.value = value // Update LiveData with new token + } + + // Clear participant token from shared preferences + fun clearParticipantToken() { + sharedPreferences.edit().remove("participantToken").apply() + _liveParticipantToken.value = null + } + + // Initialize ViewModel (add additional initialization logic if needed) + init { + // Initialization logic can be added here if necessary + } + + // Configure the chat session with global settings + private suspend fun configureChatSession() { + val globalConfig = GlobalConfig(region = chatConfiguration.region) + chatSession.configure(globalConfig) + setupChatHandlers(chatSession) + } + + // Setup event handlers for the chat session + private suspend fun setupChatHandlers(chatSession: ChatSession) { + chatSession.onConnectionEstablished = { + Log.d("ChatViewModel", "Connection established.") + _isChatActive.value = true + } + + chatSession.onMessageReceived = { transcriptItem -> + // Handle received websocket message if needed + } + + chatSession.onTranscriptUpdated = { transcriptList -> + Log.d("ChatViewModel", "Transcript onTranscriptUpdated last 3 items: ${transcriptList.takeLast(3)}") + viewModelScope.launch { + onUpdateTranscript(transcriptList) + } + } + + chatSession.onChatEnded = { + Log.d("ChatViewModel", "Chat ended.") + _isChatActive.value = false + } + + chatSession.onConnectionBroken = { + Log.d("ChatViewModel", "Connection broken.") + } + + chatSession.onConnectionReEstablished = { + Log.d("ChatViewModel", "Connection re-established.") + _isChatActive.value = true + } + + chatSession.onChatSessionStateChanged = { + Log.d("ChatViewModel", "Chat session state changed: $it") + _isChatActive.value = it + } + + chatSession.onDeepHeartBeatFailure = { + Log.d("ChatViewModel", "Deep heartbeat failure") + } + } + + // Initiate chat either by starting a new session or reconnecting using an existing token + fun initiateChat() { + viewModelScope.launch { + configureChatSession() // Configure chat session first + + _isLoading.value = true + messages = mutableStateListOf() // Clear existing messages + + // Check if participant token exists for reconnecting + participantToken?.let { + val chatDetails = ChatDetails(participantToken = it) + createParticipantConnection(chatDetails) + } ?: run { + startChat() // Start a fresh chat if no token is found + } + } + } + + // Start a new chat session by sending a StartChatRequest to the repository + private fun startChat() { + viewModelScope.launch { + _isLoading.value = true + val participantDetails = ParticipantDetails(displayName = chatConfiguration.customerName) + val request = StartChatRequest( + connectInstanceId = chatConfiguration.connectInstanceId, + contactFlowId = chatConfiguration.contactFlowId, + participantDetails = participantDetails + ) + when (val response = chatRepository.startChat(endpoint = chatConfiguration.startChatEndpoint,startChatRequest = request)) { + is Resource.Success -> { + response.data?.data?.startChatResult?.let { result -> + this@ChatViewModel.participantToken = result.participantToken + handleStartChatResponse(result) + } ?: run { + _isLoading.value = false + } + } + is Resource.Error -> { + _errorMessage.value = response.message + _isLoading.value = false + } + + is Resource.Loading -> _isLoading.value = true + } + } + } + + // Handle the response after starting a chat session + private fun handleStartChatResponse(result: StartChatResponse.Data.StartChatResult) { + viewModelScope.launch { + val chatDetails = ChatDetails( + contactId = result.contactId, + participantId = result.participantId, + participantToken = result.participantToken + ) + createParticipantConnection(chatDetails) + } + } + + // Create a connection to the participant chat session + private fun createParticipantConnection(chatDetails: ChatDetails) { + viewModelScope.launch { + _isLoading.value = true // Set loading state + val result = chatSession.connect(chatDetails) // Attempt connection + _isLoading.value = false // Clear loading state + + if (result.isSuccess) { + Log.d("ChatViewModel", "Connection successful $result") + } else if (result.isFailure) { + Log.e("ChatViewModel", "Connection failed: ${result.exceptionOrNull()}") + _errorMessage.value = result.exceptionOrNull()?.message + } + } + } + + // Update the transcript when new messages or events are received + private fun onUpdateTranscript(transcriptList: List) { + messages.clear() + viewModelScope.launch { + val updatedMessages = transcriptList.map { transcriptItem -> + // Customize events if needed + if (transcriptItem is Event) { + CommonUtils.customizeEvent(transcriptItem) + } + CommonUtils.getMessageDirection(transcriptItem) // Customize message direction + transcriptItem + } + messages.addAll(updatedMessages) + } + } + + // Send a text message through the chat session + fun sendMessage(text: String) { + viewModelScope.launch { + if (text.isNotEmpty()) { + val result = chatSession.sendMessage(ContentType.RICH_TEXT, text) + result.onSuccess { + // Handle success - update UI or state as needed + }.onFailure { exception -> + // Handle failure - update UI or state, log error, etc. + Log.e("ChatViewModel", "Error sending message: ${exception.message}") + } + } + } + } + + // Send an event through the chat session + fun sendEvent(content: String = "", contentType: ContentType) { + viewModelScope.launch { + val result = chatSession.sendEvent(contentType, content) + result.onSuccess { + // Handle success - update UI or state as needed + }.onFailure { exception -> + // Handle failure - update UI or state, log error, etc. + Log.e("ChatViewModel", "Error sending event: ${exception.message}") + } + } + } + + // Send a read receipt for a message when it appears + suspend fun sendReadEventOnAppear(message: Message) { + chatSession.sendMessageReceipt(message, MessageReceiptType.MESSAGE_READ) + } + + + // Fetch the chat transcript + fun fetchTranscript(onCompletion: (Boolean) -> Unit) { + viewModelScope.launch { + chatSession.getTranscript(ScanDirection.BACKWARD, SortKey.DESCENDING, 30, null, messages?.get(0)?.id).onSuccess { + Log.d("ChatViewModel", "Transcript fetched successfully") + onCompletion(true) + }.onFailure { + Log.e("ChatViewModel", "Error fetching transcript: ${it.message}") + onCompletion(false) + } + } + } + + // End the chat session and clear the participant token + fun endChat() { + clearParticipantToken() + viewModelScope.launch { + chatSession.disconnect() // Disconnect from chat session + } + } + + // Clear error messages + fun clearErrorMessage() { + _errorMessage.value = null + } + + // Request code for selecting an attachment + private val PICK_ATTACHMENT = 2 + + // Open the file picker for selecting an attachment + fun openFile(activity: Activity) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "*/*" + } + activity.startActivityForResult(intent, PICK_ATTACHMENT) // Start activity for result + } + + // Upload a selected attachment to the chat session + fun uploadAttachment(fileUri: Uri) { + viewModelScope.launch { + chatSession.sendAttachment(fileUri) + } + } + + // Download an attachment using its ID and file name + suspend fun downloadAttachment(attachmentId: String, fileName: String): Result { + return chatSession.downloadAttachment(attachmentId, fileName) + } +} \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentMessageView.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentMessageView.kt new file mode 100644 index 0000000..61161b3 --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentMessageView.kt @@ -0,0 +1,164 @@ +package com.amazon.connect.chat.androidchatexample.views + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Description +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.amazon.connect.chat.androidchatexample.utils.CommonUtils +import com.amazon.connect.chat.androidchatexample.viewmodel.ChatViewModel +import com.amazon.connect.chat.sdk.model.Message +import com.amazon.connect.chat.sdk.model.MessageDirection +import kotlinx.coroutines.launch +import java.net.URL + +@Composable +fun AttachmentMessageView( + message: Message, + chatViewModel: ChatViewModel, + recentOutgoingMessageID: String?, + onPreviewAttachment: (URL, String) -> Unit // Callback to handle file preview +) { + var isDownloading by remember { mutableStateOf(false) } + var downloadError by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + + Box( + modifier = Modifier + .fillMaxWidth(), + contentAlignment = if (message.messageDirection == MessageDirection.INCOMING) Alignment.CenterStart else Alignment.CenterEnd + ) { + Column( + modifier = Modifier + .padding(8.dp) + .background(Color.White, RoundedCornerShape(8.dp)) + .fillMaxWidth(0.80f), + horizontalAlignment = Alignment.End + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + message.displayName?.let { + Text( + text = it.ifEmpty { message.participant }, + color = Color.Black, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f).padding(end = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = CommonUtils.formatTime(message.timeStamp) ?: "", + color = Color.Gray, + style = MaterialTheme.typography.bodySmall + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + if (message.messageDirection == MessageDirection.INCOMING) Color(0xFFEDEDED) + else Color(0xFFABCDEF), + RoundedCornerShape(8.dp) + ) + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable { + coroutineScope.launch { + isDownloading = true + val result = message.attachmentId?.let { + chatViewModel.downloadAttachment( + it, message.text) + } + isDownloading = false + result?.fold( + onSuccess = { uri -> + onPreviewAttachment(uri, message.text) + }, + onFailure = { error -> + downloadError = "Failed to download attachment: ${error.localizedMessage}" + } + ) + } + } + .padding(8.dp) + ) { + Text( + text = message.text, + style = MaterialTheme.typography.bodyLarge, + color = Color(0xFF0056b3), + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + if (isDownloading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp)) + } else { + Icon( + imageVector = Icons.Default.Description, + contentDescription = "Attachment", + tint = Color(0xFF0056b3) + ) + } + } + downloadError?.let { + Text( + text = it, + color = Color.Red, + fontSize = 10.sp, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + + if (message.messageDirection == MessageDirection.OUTGOING && message.id == recentOutgoingMessageID) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + Text( + text = CommonUtils.customMessageStatus(message.metadata?.status), + fontSize = 10.sp, + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } +} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentTextView.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentTextView.kt new file mode 100644 index 0000000..3aa99b4 --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/AttachmentTextView.kt @@ -0,0 +1,99 @@ +package com.amazon.connect.chat.androidchatexample.views + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +@Composable +fun AttachmentTextView( + text: String, + selectedFileUri: String?, + onTextChange: (String) -> Unit, + onRemoveAttachment: () -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .background( + shape = RoundedCornerShape(10.dp), + color = Color.Gray.copy(alpha = 0.1f) + ) + .fillMaxWidth() // Ensures the entire component takes the available width + + ) { + selectedFileUri?.let { uri -> + if (uri.isNotEmpty()) { + Row( + modifier = Modifier + .padding(10.dp) + .background(Color.Gray.copy(alpha = 0.2f), RoundedCornerShape(6.dp)) + .padding(horizontal = 10.dp, vertical = 6.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = uri ?: "", + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = Color.Blue, + fontSize = 16.sp, + style = TextStyle(textDecoration = TextDecoration.Underline), + modifier = Modifier.align(Alignment.CenterVertically) + ) + IconButton(onClick = { onRemoveAttachment()}) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove attachment", + tint = Color.Red) + } + } + } + } + + TextField( + value = text, + onValueChange = { newText -> + onTextChange(newText) + }, + placeholder = { Text("Type a message") }, + colors = TextFieldDefaults.colors(), + modifier = Modifier.fillMaxWidth() + ) + } +} + + +@Composable +@Preview(showBackground = true) +fun PreviewAttachmentTextViewNoAttachment() { + AttachmentTextView( + text = "", + selectedFileUri = null, + onTextChange = {}, + onRemoveAttachment = {} + ) +} + +@Composable +@Preview(showBackground = true) +fun PreviewAttachmentTextViewWithAttachment() { + AttachmentTextView( + text = "", + selectedFileUri = "sample.pdf", + onTextChange = {}, + onRemoveAttachment = {} + ) +} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ChatComponents.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ChatComponents.kt new file mode 100644 index 0000000..7cd9801 --- /dev/null +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ChatComponents.kt @@ -0,0 +1,307 @@ +package com.amazon.connect.chat.androidchatexample.views + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.amazon.connect.chat.androidchatexample.utils.CommonUtils +import com.amazon.connect.chat.androidchatexample.viewmodel.ChatViewModel +import com.amazon.connect.chat.sdk.model.ContentType +import com.amazon.connect.chat.sdk.model.Event +import com.amazon.connect.chat.sdk.model.ListPickerContent +import com.amazon.connect.chat.sdk.model.Message +import com.amazon.connect.chat.sdk.model.MessageDirection +import com.amazon.connect.chat.sdk.model.PlainTextContent +import com.amazon.connect.chat.sdk.model.QuickReplyContent +import com.amazon.connect.chat.sdk.model.TranscriptItem +import java.net.URL + +@Composable +fun ChatMessageView( + transcriptItem: TranscriptItem, + viewModel: ChatViewModel, + recentOutgoingMessageID: String?, + onPreviewAttachment: (URL, String) -> Unit +) { + when (transcriptItem) { + is Message -> { + when (transcriptItem.messageDirection) { + MessageDirection.OUTGOING -> { + if (transcriptItem.attachmentId != null) { + AttachmentMessageView(transcriptItem, viewModel, recentOutgoingMessageID, onPreviewAttachment) + } else { + SenderChatBubble(transcriptItem, recentOutgoingMessageID) + } + } + MessageDirection.INCOMING -> { + when (val content = transcriptItem.content) { + is PlainTextContent -> { + if (transcriptItem.attachmentId != null) { + AttachmentMessageView(transcriptItem, viewModel, recentOutgoingMessageID, onPreviewAttachment) + } else { + ReceiverChatBubble(transcriptItem) + } + } + is QuickReplyContent -> QuickReplyContentView(transcriptItem, content) + is ListPickerContent -> ListPickerContentView(transcriptItem, content) + else -> Text(text = "Unsupported message type, View is missing") + } + } + MessageDirection.COMMON -> CommonChatBubble(transcriptItem) + null -> CommonChatBubble(transcriptItem) + } + } + // Add handling for other TranscriptItem subclasses if necessary + is Event -> { + EventView(transcriptItem) + } + else -> Text(text = "Unsupported transcript item type") + } +} + + +@Composable +fun SenderChatBubble(message: Message, recentOutgoingMessageID: String? = null) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + contentAlignment = Alignment.CenterEnd, + ) { + Column( + modifier = Modifier + .padding(8.dp) + .background(Color.White, RoundedCornerShape(8.dp)) + .fillMaxWidth(0.80f), + horizontalAlignment = Alignment.End, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + message.displayName?.let { + Text( + text = it.ifEmpty { message.participant }, + color = Color.Black, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(end = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = CommonUtils.formatTime(message.timeStamp) ?: "", + color = Color.Gray, + style = MaterialTheme.typography.bodySmall + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Color(0xFFABCDEF), + RoundedCornerShape(8.dp) + ) + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(8.dp) + ) { + Text( + text = message.text, + color = Color.Black, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp) + ) + } + + } + + message.metadata?.status?.let { + if (message.id == recentOutgoingMessageID) { + Text( + text = it.status, + style = MaterialTheme.typography.bodySmall, + color = Color.Gray + ) + } + } + } + } +} + +@Composable +fun ReceiverChatBubble(message: Message) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + contentAlignment = Alignment.CenterStart, + ) { + Column( + modifier = Modifier + .padding(8.dp) + .background(Color.White, RoundedCornerShape(8.dp)) + .fillMaxWidth(0.80f), + horizontalAlignment = Alignment.End, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + message.displayName?.let { + Text( + text = it.ifEmpty { message.participant }, + color = Color.Black, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(end = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = CommonUtils.formatTime(message.timeStamp) ?: "", + color = Color.Gray, + style = MaterialTheme.typography.bodySmall + ) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Color(0xFFEDEDED), + RoundedCornerShape(8.dp) + ) + .padding(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(8.dp) + ) { + Text( + text = message.text, + color = Color.Black, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier + .weight(1f) + .padding(end = 8.dp), + overflow = TextOverflow.Ellipsis + ) + } + + } + } + } +} + +@Composable +fun CommonChatBubble(message: Message) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = message.text, + color = Color.Gray, + textAlign = TextAlign.Center, + modifier = Modifier + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.75f) + ) + } +} + +@Composable +fun EventView(event: Event) { + + val isTypingEvent = event.contentType == ContentType.TYPING.type && + event.eventDirection == MessageDirection.INCOMING + val padding: Dp + val alignment: Alignment + + if (isTypingEvent) { + alignment = Alignment.CenterStart + padding = 0.dp + } else { + alignment = Alignment.Center + padding = 8.dp + } + + if (event.eventDirection != MessageDirection.OUTGOING) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .padding(top = 6.dp), + contentAlignment = alignment + ) { + if (isTypingEvent) { + Column( + modifier = Modifier + .fillMaxWidth() + .background( + Color(0xFFFFFFFF), + RoundedCornerShape(8.dp) + ) + .padding(8.dp) + ) { + event.displayName?.let { + Text( + text = it, + color = Color.Black, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .padding(4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + TypingIndicator() + } + } else if (event.eventDirection == MessageDirection.COMMON) { + event.text?.let { + Text( + style = MaterialTheme.typography.bodyLarge, + text = it, + color = Color.Black, + textAlign = TextAlign.Center, + modifier = Modifier + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.75f) + ) + } + } + } + } +} + diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/ListPickerContentView.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ListPickerContentView.kt similarity index 73% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/ListPickerContentView.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ListPickerContentView.kt index ac5ee16..35ed25f 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/ListPickerContentView.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/ListPickerContentView.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.views +package com.amazon.connect.chat.androidchatexample.views import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -23,23 +23,22 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import coil.request.ImageRequest -import com.blitz.androidchatexample.R -import com.blitz.androidchatexample.models.ListPickerContent -import com.blitz.androidchatexample.models.ListPickerElement -import com.blitz.androidchatexample.models.Message -import com.blitz.androidchatexample.viewmodel.ChatViewModel +import com.amazon.connect.chat.androidchatexample.utils.CommonUtils +import com.amazon.connect.chat.androidchatexample.viewmodel.ChatViewModel +import com.amazon.connect.chat.sdk.model.ListPickerContent +import com.amazon.connect.chat.sdk.model.ListPickerElement +import com.amazon.connect.chat.sdk.model.Message @Composable fun ListPickerContentView( @@ -48,21 +47,44 @@ fun ListPickerContentView( ) { var showListPicker by remember { mutableStateOf(true) } val viewModel: ChatViewModel = hiltViewModel() + Column( + modifier = Modifier + .padding(8.dp) + .background(Color.White, RoundedCornerShape(8.dp)) + .fillMaxWidth(0.80f), + horizontalAlignment = Alignment.End, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + message.displayName?.let { + Text( + text = it.ifEmpty { message.participant }, + color = Color.Black, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .weight(1f) + .padding(end = 4.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text( + text = CommonUtils.formatTime(message.timeStamp) ?: "", + color = Color.Gray, + style = MaterialTheme.typography.bodySmall + ) + } - if (message.participant != null) { - Text( - text = message.participant!!, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 8.dp, bottom = 4.dp) - ) - } if (showListPicker) { Column( modifier = Modifier - .padding(start = 8.dp) .clip(RoundedCornerShape(size = 10.dp)) .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.75f) - .background(color = Color(0xFF9FB980)) + .background(color = Color(0xFFEDEDED)) .padding(horizontal = 10.dp, vertical = 10.dp) ) { if (!content.imageUrl.isNullOrEmpty()) { @@ -71,7 +93,6 @@ fun ListPickerContentView( .data(content.imageUrl) .crossfade(true) .build(), - placeholder = painterResource(R.drawable.placeholder), contentDescription = "Content Description", contentScale = ContentScale.Crop, modifier = Modifier @@ -86,7 +107,7 @@ fun ListPickerContentView( ) content.subtitle?.let { Text( - text = content.subtitle, + text = content.subtitle!!, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp) ) @@ -101,29 +122,22 @@ fun ListPickerContentView( } }else { Surface( - color = Color(0xFF8BC34A), + color = Color(0xFFEDEDED), shape = RoundedCornerShape(10.dp), modifier = Modifier - .padding(start = 8.dp) - .background(Color(0xFF8BC34A), shape = RoundedCornerShape(10.dp)) - .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.75f) + .background(Color(0xFFEDEDED), shape = RoundedCornerShape(10.dp)) + .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.80f) ) { - Column(modifier = Modifier.padding(10.dp)) { + Column(modifier = Modifier.padding(10.dp).fillMaxWidth(), + horizontalAlignment = Alignment.Start) { Text( text = content.title, - color = Color.White + color = Color.Black ) - message.timeStamp?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = Color.White, - modifier = Modifier.align(Alignment.End).alpha(0.7f) - ) - } } } } + } } @Composable @@ -151,7 +165,6 @@ fun ListPickerOption( .data(imageUrl) .crossfade(true) .build(), - placeholder = painterResource(R.drawable.placeholder), contentDescription = "Content Description", contentScale = ContentScale.Crop, modifier = Modifier diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/QuickReplyContentView.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/QuickReplyContentView.kt similarity index 88% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/QuickReplyContentView.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/QuickReplyContentView.kt index f0317bc..2b4d074 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/QuickReplyContentView.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/QuickReplyContentView.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.views +package com.amazon.connect.chat.androidchatexample.views import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -23,9 +23,10 @@ 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.blitz.androidchatexample.models.Message -import com.blitz.androidchatexample.models.QuickReplyContent -import com.blitz.androidchatexample.viewmodel.ChatViewModel +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 @Composable fun QuickReplyContentView(message: Message, messageContent: QuickReplyContent) { @@ -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/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/TypingIndicator.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/TypingIndicator.kt similarity index 89% rename from mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/TypingIndicator.kt rename to mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/TypingIndicator.kt index 3408bb5..0513fed 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/TypingIndicator.kt +++ b/mobileChatExamples/androidChatExample/app/src/main/java/com/amazon/connect/chat/androidchatexample/views/TypingIndicator.kt @@ -1,4 +1,4 @@ -package com.blitz.androidchatexample.views +package com.amazon.connect.chat.androidchatexample.views import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.RepeatMode @@ -23,7 +23,7 @@ import androidx.compose.ui.unit.dp @Composable fun TypingIndicator() { - val ballSize = 8.dp + val ballSize = 6.dp val animationDuration = 300 val numberOfBalls = 3 @@ -47,12 +47,11 @@ fun TypingIndicator() { Box( contentAlignment = Alignment.Center, modifier = Modifier - .padding(10.dp) - .background(Color.LightGray, RoundedCornerShape(24.dp)) - .padding( start = 10.dp) - .padding( end = 5.dp) - .padding( top = 10.dp) - .padding( bottom = 10.dp) + .background(Color(0xFFEDEDED), RoundedCornerShape(8.dp)) + .padding( start = 15.dp) + .padding( end = 10.dp) + .padding( top = 15.dp) + .padding( bottom = 15.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { ballAnimations.forEach { anim -> diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/MainActivity.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/MainActivity.kt deleted file mode 100644 index acd19f1..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/MainActivity.kt +++ /dev/null @@ -1,421 +0,0 @@ -package com.blitz.androidchatexample - -import android.annotation.SuppressLint -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Send -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TextField -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.blur -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.blitz.androidchatexample.models.Message -import com.blitz.androidchatexample.models.MessageType -import com.blitz.androidchatexample.ui.theme.AndroidChatExampleTheme -import com.blitz.androidchatexample.utils.CommonUtils.Companion.keyboardAsState -import com.blitz.androidchatexample.utils.ContentType -import com.blitz.androidchatexample.viewmodel.ChatViewModel -import com.blitz.androidchatexample.views.ChatMessageView -import dagger.hilt.android.AndroidEntryPoint - -@AndroidEntryPoint -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContent { - AndroidChatExampleTheme { - // A surface container using the 'background' color from the theme - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background - ) { - ChatScreen() - } - } - } - } -} - -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) { - var showCustomSheet by remember { mutableStateOf(false) } - val isLoading = viewModel.isLoading.observeAsState(initial = false) - val createParticipantConnectionResult = - viewModel.createParticipantConnectionResult.observeAsState() - val webSocketUrl = viewModel.webSocketUrl.observeAsState() - var showDialog by remember { mutableStateOf(false) } - var showRestoreDialog by remember { mutableStateOf(false) } - val contactId = viewModel.liveContactId.observeAsState() - val participantToken = viewModel.liveParticipantToken.observeAsState() - var showErrorDialog by remember { mutableStateOf(false) } - val errorMessage by viewModel.errorMessage.observeAsState() - - LaunchedEffect(errorMessage) { - showErrorDialog = errorMessage != null - } - - if (showErrorDialog) { - AlertDialog( - onDismissRequest = { - showErrorDialog = false - viewModel.clearErrorMessage() - }, - title = { Text("Error") }, - text = { Text(errorMessage ?: "An unknown error occurred") }, - confirmButton = { - TextButton(onClick = { - showErrorDialog = false - viewModel.clearErrorMessage() - }) { Text("OK") } - } - ) - } - - if (showRestoreDialog) { - AlertDialog( - onDismissRequest = { showRestoreDialog = false }, - title = { Text("Restore Chat") }, - text = { Text("Do you want to restore the previous chat session?") }, - confirmButton = { - TextButton( - onClick = { - showRestoreDialog = false - viewModel.clearParticipantToken() - viewModel.initiateChat() // Restore the chat directly - } - ) { Text("Restore") } - }, - dismissButton = { - TextButton(onClick = { - showRestoreDialog = false - viewModel.clearContactId() // Clear contactId - viewModel.clearParticipantToken() - viewModel.initiateChat() // Start new chat - }) { Text("Start new") } - } - ) - } - - if (showDialog) { - AlertDialog( - onDismissRequest = { showDialog = false }, - title = { Text("End Chat") }, - text = { Text("Are you sure you want to end the chat?") }, - confirmButton = { - TextButton( - onClick = { - showDialog = false - viewModel.endChat() - showCustomSheet = false - } - ) { Text("Yes") } - }, - dismissButton = { - TextButton(onClick = { showDialog = false }) { Text("Cancel") } - } - ) - } - - Scaffold( - floatingActionButton = { - if (!showCustomSheet) { - ExtendedFloatingActionButton( - text = { - if (webSocketUrl.value == null) { - Text("Start Chat") - } else { - Text("Resume Chat") - } - }, - icon = { - if (isLoading.value) { - CircularProgressIndicator(modifier = Modifier.size(16.dp)) - } - }, - onClick = { - if (!contactId.value.isNullOrEmpty() && participantToken.value.isNullOrEmpty()) { - showRestoreDialog = true - } else { - if (webSocketUrl.value == null) { - viewModel.initiateChat() - } else { - showCustomSheet = true - } - } - }, - - ) - } - } - ) { - LaunchedEffect(isLoading.value) { - if (!isLoading.value && createParticipantConnectionResult.value != null) { - showCustomSheet = true - } - } - - ContactIdAndTokenSection(viewModel) - - AnimatedVisibility( - visible = showCustomSheet, - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - - Box( - modifier = Modifier - .fillMaxSize() - .imePadding() - .background(Color.White, RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)) - ) { - Column { - TopAppBar( - title = { - Text( - "Chat", modifier = Modifier - .fillMaxWidth() - .wrapContentWidth(Alignment.CenterHorizontally) - ) - }, - navigationIcon = { - IconButton(onClick = { showCustomSheet = false }) { - Icon(Icons.Default.ArrowBack, contentDescription = "Back") - } - }, - actions = { - TextButton(onClick = { - showDialog = true - }) { - Text("End Chat") - } - } - ) - ChatView(viewModel = viewModel) // Your chat view composable - } - } - } - } -} - -@Composable -fun ChatView(viewModel: ChatViewModel) { - val messages by viewModel.messages.observeAsState(listOf()) - var textInput by remember { mutableStateOf("") } - val listState = rememberLazyListState() - val isKeyboardVisible = keyboardAsState().value - var isChatEnded by remember { mutableStateOf(false) } - - LaunchedEffect(messages, isKeyboardVisible) { - if (messages.isNotEmpty() ) { - listState.animateScrollToItem(messages.lastIndex) - } - - if (isKeyboardVisible){ - viewModel.sendEvent(contentType = ContentType.TYPING) - } - } - - Column( - modifier = Modifier - .fillMaxSize() - .imePadding() - ) { - // Display the chat messages - LazyColumn(state = listState, modifier = Modifier.weight(1f)) { - itemsIndexed(messages) { index, message -> - ChatMessage(message) - LaunchedEffect(key1 = message, key2 = index) { - if (message.contentType == ContentType.ENDED.type) { - isChatEnded = true - viewModel.clearParticipantToken() - }else{ - isChatEnded = false - } - // Logic to determine if the message is visible. - // For simplicity, let's say it's visible if it's one of the last three messages. - if (index >= messages.size - 3 && message.messageType == MessageType.RECEIVER) { - viewModel.sendReadEventOnAppear(message) - } - } - } - } - Row( - modifier = Modifier - .padding(16.dp) - .padding(bottom = 8.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - TextField( - value = textInput, - onValueChange = { textInput = it }, - modifier = Modifier.weight(1f), - placeholder = { Text("Type a message") }, - enabled = !isChatEnded - ) - IconButton(onClick = { - viewModel.sendMessage(textInput) - textInput = "" - }, - enabled = !isChatEnded, - modifier = if (isChatEnded) Modifier.blur(2.dp) else Modifier - ) { - Icon(Icons.Default.Send, contentDescription = "Send") - } - } - } -} - -@Composable -fun ChatMessage(message: Message) { - // Customize this composable to display each message - ChatMessageView(message = message) -} - -@Composable -fun ContactIdAndTokenSection(viewModel: ChatViewModel) { - val contactId by viewModel.liveContactId.observeAsState() - 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") - } - } -} - - -// Temporary composable for preview purposes -@Composable -fun ChatViewPreview(messages: List) { - Column(modifier = Modifier.fillMaxSize()) { - LazyColumn(modifier = Modifier.weight(1f)) { - items(messages) { message -> - ChatMessage(message) - } - } - // Rest of the ChatView layout - // ... - } -} - -// Preview annotation -@Preview(showBackground = true) -@Composable -fun ChatViewPreview() { - val sampleMessages = listOf( - Message( - participant = "CUSTOMER", - text = "Hello asdfioahsdfoas idfuoasdfihjasdlfihjsoadfjopasoaisdfhjoasidjf ", - contentType = "text/plain", - messageType = MessageType.SENDER, - timeStamp = "06=51", - status = "Delivered" - ), - Message( - participant = "SYSTEM", - text = "{\"templateType\":\"ListPicker\",\"version\":\"1.0\",\"data\":{\"content\":{\"title\":\"Which department do you want to select?\",\"subtitle\":\"Tap to select option\",\"imageType\":\"URL\",\"imageData\":\"https://amazon-connect-interactive-message-blog-assets.s3-us-west-2.amazonaws.com/interactive-images/company.jpg\",\"elements\":[{\"title\":\"Billing\",\"subtitle\":\"Request billing information\",\"imageType\":\"URL\",\"imageData\":\"https://amazon-connect-interactive-message-blog-assets.s3-us-west-2.amazonaws.com/interactive-images/billing.jpg\"},{\"title\":\"New Service\",\"subtitle\":\"Set up a new service\",\"imageType\":\"URL\",\"imageData\":\"https://amazon-connect-interactive-message-blog-assets.s3-us-west-2.amazonaws.com/interactive-images/new_service.jpg\"},{\"title\":\"Cancellation\",\"subtitle\":\"Request a cancellation\",\"imageType\":\"URL\",\"imageData\":\"https://amazon-connect-interactive-message-blog-assets.s3-us-west-2.amazonaws.com/interactive-images/cancel.jpg\"}]}}}", - contentType = "application/vnd.amazonaws.connect.message.interactive", - messageType = MessageType.RECEIVER, - timeStamp = "14:18", - messageID = "f905d16e-12a0-4854-9079-d5b34476c3ba", - status = null, - isRead = false - ), - Message( - participant = "AGENT", - text = "...", - contentType = "text/plain", - messageType = MessageType.RECEIVER, - timeStamp = "06:51", - isRead = true - ), - Message( - participant = "AGENT", - text = "Hello, **this** is a agent \n\n speaking.Hello, this is a agent speaking.", - contentType = "text/plain", - messageType = MessageType.RECEIVER, - timeStamp = "06:51", - isRead = true - ), - - Message( - participant = "SYSTEM", - text = "{\"templateType\":\"QuickReply\",\"version\":\"1.0\",\"data\":{\"content\":{\"title\":\"How was your experience?\",\"elements\":[{\"title\":\"Very unsatisfied\"},{\"title\":\"Unsatisfied\"},{\"title\":\"Neutral\"},{\"title\":\"Satisfied\"},{\"title\":\"Very Satisfied\"}]}}}", - contentType = "application/vnd.amazonaws.connect.message.interactive", - messageType = MessageType.RECEIVER, - timeStamp = "06:20", - messageID = "8f76a266-6654-434f-94ea-87ec111ee341", - status = null, - isRead = false - ), - - Message( - participant = "SYSTEM", - text = "{\"templateType\":\"ListPicker\",\"version\":\"1.0\",\"data\":{\"content\":{\"title\":\"Which department would you like?\",\"subtitle\":\"Tap to select option\",\"elements\":[{\"title\":\"Billing\",\"subtitle\":\"For billing issues\"},{\"title\":\"New Service\",\"subtitle\":\"For new service\"},{\"title\":\"Cancellation\",\"subtitle\":\"For new service requests\"}]}}}", - contentType = "application/vnd.amazonaws.connect.message.interactive", - messageType = MessageType.RECEIVER, - timeStamp = "14:18", - messageID = "f905d16e-12a0-4854-9079-d5b34476c3ba", - status = null, - isRead = false - ), - - Message( - participant = "SYSTEM", - text = "Someone joined the chat.Someone joined the chat.Someone joined the chat.", - contentType = "text/plain", - messageType = MessageType.COMMON, - timeStamp = "06:51", - isRead = true - ) - ) - - ChatViewPreview(messages = sampleMessages) -} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/Message.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/Message.kt deleted file mode 100644 index 1bef8d9..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/Message.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.blitz.androidchatexample.models - -import com.blitz.androidchatexample.utils.ContentType -import kotlinx.serialization.SerializationException -import kotlinx.serialization.json.Json -import java.util.UUID - -enum class MessageType{ - SENDER, - RECEIVER, - COMMON -} - -data class Message ( - var participant: String?, - var text: String, - val id: UUID = UUID.randomUUID(), - var contentType: String, - var messageType: MessageType, - var timeStamp: String? = null, - var messageID: String? = null, - var status: String? = null, - var isRead: Boolean = false -){ - val content: MessageContent? - get() = when (contentType) { - ContentType.PLAIN_TEXT.type -> PlainTextContent.decode(text) - ContentType.RICH_TEXT.type -> PlainTextContent.decode(text) // You can replace this with a rich text class later - ContentType.INTERACTIVE_TEXT.type -> decodeInteractiveContent(text) - else -> null // Handle unsupported content types - } - - // Helper method to decode interactive content - private fun decodeInteractiveContent(text: String): InteractiveContent? { - val jsonData = text.toByteArray(Charsets.UTF_8) - val genericTemplate = try { - Json { ignoreUnknownKeys = true }.decodeFromString(String(jsonData)) - } catch (e: SerializationException) { - null - } - return when (genericTemplate?.templateType) { - QuickReplyContent.templateType -> QuickReplyContent.decode(text) - ListPickerContent.templateType -> ListPickerContent.decode(text) - // Add cases for each interactive message type, decoding as appropriate. - else -> { - println("Unsupported interactive content type: ${genericTemplate?.templateType}") - null - } - } - } - -} \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/MessageContent.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/MessageContent.kt deleted file mode 100644 index 1567270..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/MessageContent.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.blitz.androidchatexample.models - -import kotlinx.serialization.Serializable -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json - -interface MessageContent { -} - -// Plain Text -@Serializable -data class PlainTextContent(val text: String) : MessageContent { - companion object{ - fun decode(text: String): MessageContent = PlainTextContent(text) - } -} - -// Generic Interactive Template -@Serializable -data class GenericInteractiveTemplate( - val templateType: String - // Other common properties -) - -interface InteractiveContent : MessageContent { -} - -// Quick Reply Content -@Serializable -data class QuickReplyElement( - val title: String -) -@Serializable -data class QuickReplyContentData( - val title: String, - val subtitle: String? = null, - val elements: List -) -@Serializable -data class QuickReplyData( - val content: QuickReplyContentData -) -@Serializable -data class QuickReplyTemplate( - val templateType: String, - val version: String, - val data: QuickReplyData -) -@Serializable -data class QuickReplyContent( - val title: String, - val subtitle: String? = null, - val options: List -) : InteractiveContent { - companion object { - const val templateType = "QuickReply" - fun decode(text: String): InteractiveContent? { - return try { - val quickReply = Json.decodeFromString(text) - val options = quickReply.data.content.elements.map { it.title } - val title = quickReply.data.content.title - val subtitle = quickReply.data.content.subtitle ?: "" // Fallback to empty string if null - QuickReplyContent(title, subtitle, options) - } catch (e: Exception) { - println("Error decoding QuickReplyContent: ${e.message}") - null - } - } - } -} - -// List Picker -@Serializable -data class ListPickerElement( - val title: String, - val subtitle: String? = null, - val imageType: String? = null, - val imageData: String? = null -) -@Serializable -data class ListPickerContentData( - val title: String, - val subtitle: String? = null, - val imageType: String? = null, - val imageData: String? = null, - val elements: List -) -@Serializable -data class ListPickerData( - val content: ListPickerContentData -) -@Serializable -data class ListPickerTemplate( - val templateType: String, - val version: String, - val data: ListPickerData -) -@Serializable -data class ListPickerContent( - val title: String, - val subtitle: String? = null, - val imageUrl: String? = null, - val options: List -) : InteractiveContent { - companion object { - const val templateType = "ListPicker" - fun decode(text: String): InteractiveContent? { - return try { - val listPicker = Json.decodeFromString(text) - val title = listPicker.data.content.title - val subtitle = listPicker.data.content.subtitle ?: "" - val options = listPicker.data.content.elements - val imageUrl = listPicker.data.content.imageData ?: "" - ListPickerContent(title, subtitle, imageUrl, options) - } catch (e: Exception) { - println("Error decoding ListPickerContent: ${e.message}") - null - } - } - } -} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/TranscriptResponse.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/TranscriptResponse.kt deleted file mode 100644 index fea6718..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/models/TranscriptResponse.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.blitz.androidchatexample.models - -data class TranscriptResponse( - val initialContactId: String, - val nextToken: String?, - val transcript: List -) - -data class TranscriptItem( - val absoluteTime: String, - val content: String?, - val contentType: String, - val displayName: String?, - val id: String, - val participantId: String?, - val participantRole: String?, - val type: String, - val messageMetadata: MessageMetadata? -) - -data class MessageMetadata( - val messageId: String, - val receipts: List? -) - -data class Receipt( - val deliveredTimestamp: String?, - val readTimestamp: String?, - val recipientParticipantId: String -) diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/ApiInterface.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/ApiInterface.kt deleted file mode 100644 index bfe4435..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/network/ApiInterface.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.blitz.androidchatexample.network - -import com.blitz.androidchatexample.models.StartChatRequest -import com.blitz.androidchatexample.models.StartChatResponse -import retrofit2.http.Body -import retrofit2.http.POST -import javax.inject.Singleton - -@Singleton -interface ApiInterface { - @POST("Prod/") - suspend fun startChat(@Body request: StartChatRequest): StartChatResponse -} \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/repository/ChatRepository.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/repository/ChatRepository.kt deleted file mode 100644 index 582b524..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/repository/ChatRepository.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.blitz.androidchatexample.repository - -import com.amazonaws.AmazonClientException -import com.amazonaws.AmazonServiceException -import com.amazonaws.handlers.AsyncHandler -import com.amazonaws.regions.Region -import com.amazonaws.services.connectparticipant.AmazonConnectParticipantAsyncClient -import com.amazonaws.services.connectparticipant.model.CreateParticipantConnectionRequest -import com.amazonaws.services.connectparticipant.model.CreateParticipantConnectionResult -import com.amazonaws.services.connectparticipant.model.DisconnectParticipantRequest -import com.amazonaws.services.connectparticipant.model.GetTranscriptRequest -import com.amazonaws.services.connectparticipant.model.GetTranscriptResult -import com.amazonaws.services.connectparticipant.model.SendEventRequest -import com.amazonaws.services.connectparticipant.model.SendEventResult -import com.amazonaws.services.connectparticipant.model.SendMessageRequest -import com.amazonaws.services.connectparticipant.model.SendMessageResult -import com.blitz.androidchatexample.Config -import com.blitz.androidchatexample.models.MessageMetadata -import com.blitz.androidchatexample.models.Receipt -import com.blitz.androidchatexample.models.StartChatRequest -import com.blitz.androidchatexample.models.StartChatResponse -import com.blitz.androidchatexample.models.TranscriptItem -import com.blitz.androidchatexample.models.TranscriptResponse -import com.blitz.androidchatexample.network.ApiInterface -import com.blitz.androidchatexample.network.Resource -import com.blitz.androidchatexample.utils.ContentType -import dagger.hilt.android.scopes.ActivityScoped -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.json.JSONObject -import retrofit2.HttpException -import java.lang.Exception -import javax.inject.Inject - -@ActivityScoped -class ChatRepository @Inject constructor( - private val apiInterface: ApiInterface -) { - private var connectParticipantClient: AmazonConnectParticipantAsyncClient = - AmazonConnectParticipantAsyncClient() - private val chatConfiguration = Config - - init { - connectParticipantClient.setRegion(Region.getRegion(chatConfiguration.region)) - } - - // StartChat API: https://docs.aws.amazon.com/connect/latest/APIReference/API_StartChatContact.html - // Android SDK Docs: https://github.com/aws-amplify/aws-sdk-android - suspend fun startChat(startChatRequest: StartChatRequest): Resource { - return try { - val response = apiInterface.startChat(startChatRequest) - Resource.Success(response) - } catch (e: HttpException) { - val errorBody = e.response()?.errorBody()?.string() - val errorMessage = parseErrorMessageFromJson(errorBody) - Resource.Error(errorMessage ?: "Unknown error occurred") - } catch (e: Exception) { - Resource.Error("An unknown error occurred: ${e.localizedMessage}") - } - } - - private fun parseErrorMessageFromJson(jsonString: String?): String? { - return try { - JSONObject(jsonString).getJSONObject("data").getJSONObject("Error").getString("message") - } catch (e: Exception) { - null // Return null if there is an issue parsing the JSON - } - } - - - /// Creates the participant's connection. https://docs.aws.amazon.com/connect-participant/latest/APIReference/API_CreateParticipantConnection.html - /// - Parameter participantToken: The ParticipantToken as obtained from StartChatContact API response. - fun createParticipantConnection( - token: String, - handler: AsyncHandler - ) { - val createParticipantConnectionRequest = CreateParticipantConnectionRequest().apply { - setType(listOf("WEBSOCKET", "CONNECTION_CREDENTIALS")) - participantToken = token - } - connectParticipantClient.createParticipantConnectionAsync( - createParticipantConnectionRequest, - handler - ) - } - - suspend fun sendMessage( - token: String, - text: String - ): Result = withContext(Dispatchers.IO) { - return@withContext try { - val sendMessageRequest = SendMessageRequest().apply { - connectionToken = token - content = text - contentType = "text/plain" - } - val response = connectParticipantClient.sendMessage(sendMessageRequest) - Result.success(response) - } catch (e: AmazonClientException) { - // Handle client-side exceptions - Result.failure(e) - } catch (e: AmazonServiceException) { - // Handle service-side exceptions - Result.failure(e) - } - } - - suspend fun sendEvent( - token: String, - _contentType: ContentType, - _content: String = "" - ): Result = withContext(Dispatchers.IO) { - return@withContext try { - val sendEventRequest = SendEventRequest().apply { - connectionToken = token - content = _content - contentType = _contentType.type - } - val response = connectParticipantClient.sendEvent(sendEventRequest) - Result.success(response) - } catch (e: AmazonClientException) { - // Handle client-side exceptions - Result.failure(e) - } catch (e: AmazonServiceException) { - // Handle service-side exceptions - Result.failure(e) - } - } - - suspend fun disconnectParticipant( - token: String, - ) = withContext(Dispatchers.IO) { - return@withContext try { - val disconnectParticipantRequest = DisconnectParticipantRequest().apply { - connectionToken = token - } - val response = - connectParticipantClient.disconnectParticipant(disconnectParticipantRequest) - Result.success(response) - } catch (e: AmazonClientException) { - // Handle client-side exceptions - Result.failure(e) - } catch (e: AmazonServiceException) { - // Handle service-side exceptions - Result.failure(e) - } - } - - suspend fun getAllTranscripts(connectionToken: String): Resource { - var accumulatedItems = mutableListOf() - var fullResponse: TranscriptResponse? = null - var nextToken: String? = null - - do { - val response = getTranscript(connectionToken, nextToken) - if (response is Resource.Success) { - response.data?.let { - accumulatedItems.addAll(it.transcript) - nextToken = it.nextToken - fullResponse = it.copy(transcript = accumulatedItems) // Create new response with all items - } - } else { - return Resource.Error("Failed to fetch all transcript items.") - } - } while (nextToken != null && nextToken!!.isNotEmpty()) - - return Resource.Success(fullResponse ?: return Resource.Error("Failed to obtain full transcript response.")) - } - - private suspend fun getTranscript(connectionToken: String, nextToken: String? = null): Resource = withContext(Dispatchers.IO) { - try { - val request = GetTranscriptRequest().apply { - this.connectionToken = connectionToken - this.nextToken = nextToken - this.maxResults = 100 // Adjust as needed - this.scanDirection = "BACKWARD" // Or "FORWARD" as needed - } - - val result = connectParticipantClient.getTranscript(request) - val transcriptItems: List = result.transcript.map { apiItem -> - TranscriptItem( - absoluteTime = apiItem.absoluteTime, - content = apiItem.content, - contentType = apiItem.contentType, - displayName = apiItem.displayName, - id = apiItem.id, - participantId = apiItem.participantId, - participantRole = apiItem.participantRole, - type = apiItem.type, - messageMetadata = apiItem.messageMetadata?.let { metadata -> - MessageMetadata( - messageId = metadata.messageId, - receipts = metadata.receipts?.map { receipt -> - Receipt( - deliveredTimestamp = receipt.deliveredTimestamp, - readTimestamp = receipt.readTimestamp, - recipientParticipantId = receipt.recipientParticipantId - ) - } - ) - } - ) - } - - // Create the full response object - val fullResponse = TranscriptResponse( - initialContactId = result.initialContactId, - nextToken = result.nextToken, - transcript = transcriptItems - ) - - Resource.Success(fullResponse) - - } catch (e: Exception) { - Resource.Error(e.message ?: "Unknown error occurred while fetching chat transcript.") - } - } - - - -} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/repository/WebSocketManager.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/repository/WebSocketManager.kt deleted file mode 100644 index 29f565a..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/repository/WebSocketManager.kt +++ /dev/null @@ -1,209 +0,0 @@ -package com.blitz.androidchatexample.repository - -import android.util.Log -import com.blitz.androidchatexample.Config -import com.blitz.androidchatexample.models.Message -import com.blitz.androidchatexample.models.MessageType -import com.blitz.androidchatexample.models.TranscriptItem -import com.blitz.androidchatexample.utils.CommonUtils -import com.blitz.androidchatexample.utils.ContentType -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import okhttp3.WebSocket -import okhttp3.WebSocketListener -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import org.json.JSONObject - -class WebSocketManager { - private val client: OkHttpClient = OkHttpClient.Builder() - .pingInterval(60, TimeUnit.SECONDS) - .build() - private var webSocket: WebSocket? = null - private lateinit var messageCallBack : (Message) -> Unit - private val chatConfiguration = Config - - fun createWebSocket(url: String, onMessageReceived: (Message) -> Unit, onConnectionFailed: (String) -> Unit) { - val request = Request.Builder().url(url).build() - this.messageCallBack = onMessageReceived - webSocket = client.newWebSocket(request, object : WebSocketListener() { - override fun onOpen(ws: WebSocket, response: Response) { - // Handle WebSocket open event - sendMessage("{\"topic\": \"aws/subscribe\", \"content\": {\"topics\": [\"aws/chat\"]}}") - } - - override fun onMessage(ws: WebSocket, text: String) { - Log.i("text@onMessage",text) - websocketDidReceiveMessage(text) - } - - override fun onClosing(ws: WebSocket, code: Int, reason: String) { - // Handle WebSocket closing event - } - - override fun onClosed(ws: WebSocket, code: Int, reason: String) { - // Handle WebSocket closed event - } - - override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { - onConnectionFailed(t.message ?: "Unknown Error") - } - }) - } - - fun websocketDidReceiveMessage(text: String) { - val json = JSONObject(text) - val content = json.opt("content") - if (content is String) { - val contentJson = JSONObject(content) - contentJson?.let { - if (it.has("Type") && it.has("ContentType")) { - val type = it.getString("Type") - val contentType = it.getString("ContentType") - when { - type == "MESSAGE" -> handleMessage(it) - contentType == ContentType.JOINED.type -> handleParticipantJoined(it) - contentType == ContentType.LEFT.type -> handleParticipantLeft(it) - contentType == ContentType.TYPING.type -> handleTyping(it) - contentType == ContentType.ENDED.type -> handleChatEnded(it) - contentType == ContentType.META_DATA.type -> handleMetadata(it) - } - } - } - } - } - - private fun handleMessage(innerJson: JSONObject) { - val participantRole = innerJson.getString("ParticipantRole") - val messageId = innerJson.getString("Id") - var messageText = innerJson.getString("Content") - val messageType = if (participantRole.equals(chatConfiguration.customerName, ignoreCase = true)) MessageType.SENDER else MessageType.RECEIVER - val time = CommonUtils.formatTime(innerJson.getString("AbsoluteTime")) - val message = Message( - participant = participantRole, - text = messageText, - contentType = innerJson.getString("ContentType"), - messageType = messageType, - timeStamp = time, - messageID = messageId - ) - this.messageCallBack(message) - } - - private fun handleParticipantJoined(innerJson: JSONObject) { - val participantRole = innerJson.getString("ParticipantRole") - val messageText = "$participantRole has joined" - val message = Message( - participant = participantRole, - text = messageText, - contentType = innerJson.getString("ContentType"), - messageType = MessageType.COMMON - ) - this.messageCallBack(message) - } - - private fun handleParticipantLeft(innerJson: JSONObject) { - val participantRole = innerJson.getString("ParticipantRole") - val messageText = "$participantRole has left" - val message = Message( - participant = participantRole, - text = messageText, - contentType = innerJson.getString("ContentType"), - messageType = MessageType.COMMON - ) - this.messageCallBack(message) - } - - private fun handleTyping(innerJson: JSONObject) { - val participantRole = innerJson.getString("ParticipantRole") - val time = CommonUtils.formatTime(innerJson.getString("AbsoluteTime")) - val messageType = if (participantRole.equals(chatConfiguration.customerName, ignoreCase = true)) MessageType.SENDER else MessageType.RECEIVER - val message = Message( - participant = participantRole, - text = "...", - contentType = innerJson.getString("ContentType"), - messageType = messageType, - timeStamp = time - ) - this.messageCallBack(message) } - - private fun handleChatEnded(innerJson: JSONObject) { - val message = Message( - participant = "System Message", - text = "The chat has ended.", - contentType = innerJson.getString("ContentType"), - messageType = MessageType.COMMON - ) - this.messageCallBack(message) - } - - private fun handleMetadata(innerJson: JSONObject) { - val messageMetadata = innerJson.getJSONObject("MessageMetadata") - val messageId = messageMetadata.getString("MessageId") - val receipts = messageMetadata.optJSONArray("Receipts") - var status = "Delivered" - val time = CommonUtils.formatTime(innerJson.getString("AbsoluteTime")) - receipts?.let { - for (i in 0 until it.length()) { - val receipt = it.getJSONObject(i) - if (receipt.optString("ReadTimestamp").isNotEmpty()) { - status = "Read" - } - } - } - val message = Message( - participant = "", - text = "", - contentType = innerJson.getString("ContentType"), - messageType = MessageType.SENDER, - timeStamp = time, - messageID = messageId, - status = status - ) - this.messageCallBack(message) - } - - - fun closeWebSocket() { - CoroutineScope(Dispatchers.IO).launch { - webSocket?.close(1000, null) - } - } - - fun sendMessage(message: String) { - CoroutineScope(Dispatchers.IO).launch { - webSocket?.send(message) - } - } - - fun formatAndProcessTranscriptItems(transcriptItems: List) { - transcriptItems.forEach { item -> - val participantRole = item.participantRole - - // Create the message content in JSON format - val messageContentJson = JSONObject().apply { - put("Id", item.id ?: "") - put("ParticipantRole", participantRole) - put("AbsoluteTime", item.absoluteTime ?: "") - put("ContentType", item.contentType ?: "") - put("Content", item.content ?: "") - put("Type", item.type) - put("DisplayName", item.displayName ?: "") - } - - // Convert JSON object to String format - val messageContentString = messageContentJson.toString() - - // Prepare the message in the format expected by WebSocket - val wrappedMessageString = "{\"content\":\"${messageContentString.replace("\"", "\\\"")}\"}" - - // Send the formatted message string via WebSocket - websocketDidReceiveMessage(wrappedMessageString) - } - } - -} - diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/utils/CommonUtils.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/utils/CommonUtils.kt deleted file mode 100644 index 7462836..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/utils/CommonUtils.kt +++ /dev/null @@ -1,115 +0,0 @@ -package com.blitz.androidchatexample.utils - -import android.view.ViewTreeObserver -import androidx.compose.foundation.text.ClickableText -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.State -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.platform.LocalWindowInfo -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration -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 { - val view = LocalView.current - var isImeVisible by remember { mutableStateOf(false) } - DisposableEffect(LocalWindowInfo.current) { - val listener = ViewTreeObserver.OnPreDrawListener { - isImeVisible = ViewCompat.getRootWindowInsets(view) - ?.isVisible(WindowInsetsCompat.Type.ime()) == true - true - } - view.viewTreeObserver.addOnPreDrawListener(listener) - onDispose { - view.viewTreeObserver.removeOnPreDrawListener(listener) - } - } - return rememberUpdatedState(isImeVisible) - } - - @Composable - fun MarkdownText(text: String, color: Color) { - val annotatedString = buildAnnotatedString { - // Set default text color to white - withStyle(style = SpanStyle(color = color, fontSize = 18.sp),) { - var currentIndex = 0 - val regex = """\*\*(.*?)\*\*|\[(.*?)\]\((.*?)\)""".toRegex() - regex.findAll(text).forEach { matchResult -> - val (beforeMatchIndex, matchIndex) = matchResult.range.first to matchResult.range.last + 1 - // Add normal text - append(text.substring(currentIndex, beforeMatchIndex)) - // Check match type (bold or link) - val (boldText, linkText, linkUrl) = matchResult.destructured - if (boldText.isNotEmpty()) { - // Bold text - withStyle(style = SpanStyle(fontWeight = FontWeight.Bold, color = color, fontSize = 18.sp)) { - append(boldText) - } - } else { - // Link - pushStringAnnotation(tag = "URL", annotation = linkUrl) - withStyle(style = SpanStyle(color = Color.Blue, textDecoration = TextDecoration.Underline, fontSize = 18.sp)) { - append(linkText) - } - pop() - } - currentIndex = matchIndex - } - // Add remaining text - append(text.substring(currentIndex)) - } - } - - ClickableText( - text = annotatedString, - onClick = { offset -> - annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset) - .firstOrNull()?.let { annotation -> - println("Clicked URL: ${annotation.item}") - } - } - ) - } - - fun parseErrorMessage(rawMessage: String?): String { - return rawMessage ?: "An unknown error occurred" - } - - } - - -} \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/utils/ContentType.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/utils/ContentType.kt deleted file mode 100644 index 810931a..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/utils/ContentType.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.blitz.androidchatexample.utils - -enum class ContentType(val type: String){ - TYPING("application/vnd.amazonaws.connect.event.typing"), - CONNECTION_ACKNOWLEDGED("application/vnd.amazonaws.connect.event.connection.acknowledged"), - MESSAGE_DELIVERED("application/vnd.amazonaws.connect.event.message.delivered"), - MESSAGE_READ("application/vnd.amazonaws.connect.event.message.read"), - META_DATA("application/vnd.amazonaws.connect.event.message.metadata"), - JOINED("application/vnd.amazonaws.connect.event.participant.joined"), - LEFT("application/vnd.amazonaws.connect.event.participant.left"), - ENDED("application/vnd.amazonaws.connect.event.chat.ended"), - PLAIN_TEXT("text/plain"), - RICH_TEXT("text/markdown"), - INTERACTIVE_TEXT("application/vnd.amazonaws.connect.message.interactive") -} \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/viewmodel/ChatViewModel.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/viewmodel/ChatViewModel.kt deleted file mode 100644 index abcebdf..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/viewmodel/ChatViewModel.kt +++ /dev/null @@ -1,291 +0,0 @@ -package com.blitz.androidchatexample.viewmodel - -import android.content.SharedPreferences -import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import com.blitz.androidchatexample.models.StartChatResponse -import com.blitz.androidchatexample.network.Resource -import com.blitz.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.blitz.androidchatexample.Config -import com.blitz.androidchatexample.models.Message -import com.blitz.androidchatexample.models.ParticipantDetails -import com.blitz.androidchatexample.models.PersistentChat -import com.blitz.androidchatexample.models.StartChatRequest -import com.blitz.androidchatexample.repository.WebSocketManager -import com.blitz.androidchatexample.utils.CommonUtils.Companion.parseErrorMessage -import com.blitz.androidchatexample.utils.ContentType -import kotlinx.coroutines.launch - -@HiltViewModel -class ChatViewModel @Inject constructor( - private val chatRepository: ChatRepository, - private val sharedPreferences: SharedPreferences -) : ViewModel() { - private val chatConfiguration = Config - private val _isLoading = MutableLiveData(false) - val isLoading: MutableLiveData = _isLoading - private val _startChatResponse = MutableLiveData>() - private val startChatResponse: LiveData> = _startChatResponse - private val _createParticipantConnectionResult = MutableLiveData() - val createParticipantConnectionResult: MutableLiveData = _createParticipantConnectionResult - private val webSocketManager = WebSocketManager() - private val _messages = MutableLiveData>() - val messages: LiveData> = _messages - private val _webSocketUrl = MutableLiveData() - val webSocketUrl: MutableLiveData = _webSocketUrl - 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) { - sharedPreferences.edit().putString("participantToken", value).apply() - _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 - } - - fun initiateChat() { - viewModelScope.launch { - _isLoading.value = true - _messages.postValue(emptyList()) // Clear existing messages - if (participantToken != null) { - participantToken?.let { createParticipantConnection(it) } - } else if (contactId != null) { - startChat(contactId) - } else { - startChat(null) // Start a fresh chat if no tokens are present - } - } - } - - private fun startChat(sourceContactId: String?) { - 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 - ) - 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 - createParticipantConnection(result.participantToken) - } ?: run { - _isLoading.value = false - } - } - is Resource.Error -> { - _errorMessage.value = response.message - _isLoading.value = false - clearContactId() - } - - is Resource.Loading -> _isLoading.value = true - } - } - } - - private fun createParticipantConnection(participantToken: String) { - viewModelScope.launch { - _isLoading.value = true // Start loading - chatRepository.createParticipantConnection( - participantToken, - object : AsyncHandler { - override fun onError(exception: Exception?) { - Log.e("ChatViewModel", "CreateParticipantConnection failed: ${exception?.localizedMessage}") - clearParticipantToken() - _errorMessage.value = parseErrorMessage(exception?.localizedMessage) - _isLoading.postValue(false) - } - override fun onSuccess(request: CreateParticipantConnectionRequest?, result: CreateParticipantConnectionResult?) { - viewModelScope.launch { - result?.let { connectionResult -> - _createParticipantConnectionResult.value = connectionResult - val websocketUrl = connectionResult.websocket?.url - _webSocketUrl.value = websocketUrl - websocketUrl?.let { wsUrl -> - webSocketManager.createWebSocket( - wsUrl, - this@ChatViewModel::onMessageReceived, - this@ChatViewModel::onWebSocketError - ) - } - connectionResult.connectionCredentials?.connectionToken?.let { cToken -> - val transcriptsResource = - chatRepository.getAllTranscripts(cToken) - if (transcriptsResource is Resource.Success) { - transcriptsResource.data?.transcript?.let { transcriptItems -> - Log.d("ChatViewModel:GetTranscript", - transcriptItems.toString() - ) - webSocketManager.formatAndProcessTranscriptItems( - transcriptItems.reversed() - ) - } - } else { - Log.e( - "ChatViewModel", - "Error fetching transcripts: ${transcriptsResource.message}" - ) - _errorMessage.value = parseErrorMessage("Error fetching transcripts: ${transcriptsResource.message}") - } - } - _isLoading.postValue(false) // End loading - } ?: run { - Log.e( - "ChatViewModel", - "CreateParticipantConnection returned null result" - ) - _errorMessage.value = parseErrorMessage("CreateParticipantConnection returned null result") - _isLoading.postValue(false) // End loading - } - } - } - } - ) - } - } - - private fun onMessageReceived(message: Message) { - // Log the current state before the update - viewModelScope.launch { - - Log.i("ChatViewModel", "Received message: $message") - - // Construct the new list with modifications based on the received message - val updatedMessages = _messages.value.orEmpty().toMutableList().apply { - // Filter out typing indicators and apply message status updates or add new messages - removeIf { it.text == "..." } - if (message.contentType == ContentType.META_DATA.type) { - val index = indexOfFirst { it.messageID == message.messageID } - if (index != -1) { - this[index] = get(index).copy(status = message.status) - } - } else { - // Exclude customer's typing events - if (!(message.text == "..." && message.participant == chatConfiguration.customerName)) { - add(message) - } - } - } - - // Update messages LiveData in a thread-safe manner - _messages.value =updatedMessages - - // Additional logic like sending 'Delivered' events - if (message.participant == chatConfiguration.agentName && message.contentType.contains("text")) { - val content = "{\"messageId\":\"${message.messageID}\"}" - sendEvent(content, ContentType.MESSAGE_DELIVERED) - } - } - } - - - private fun onWebSocketError(errorMessage: String) { - // Handle WebSocket errors - _isLoading.postValue(false) - } - - fun sendMessage(text: String) { - if (text.isNotEmpty()) { - createParticipantConnectionResult.value?.connectionCredentials?.let { credentials -> - viewModelScope.launch { - val result = chatRepository.sendMessage(credentials.connectionToken, text) - result.onSuccess { - // Handle success - update UI or state as needed - }.onFailure { exception -> - // Handle failure - update UI or state, log error, etc. - Log.e("ChatViewModel", "Error sending message: ${exception.message}") - } - } - } - } - } - - fun sendEvent(content: String = "", contentType: ContentType) { - createParticipantConnectionResult.value?.connectionCredentials?.let { credentials -> - viewModelScope.launch { - val result = chatRepository.sendEvent(credentials.connectionToken, contentType,content) - result.onSuccess { - // Handle success - update UI or state as needed - }.onFailure { exception -> - // Handle failure - update UI or state, log error, etc. - Log.e("ChatViewModel", "Error sending Event: ${exception.message}") - } - } - } - } - - fun sendReadEventOnAppear(message: Message) { - val messagesList = (_messages.value ?: return).toMutableList() - val index = messagesList.indexOfFirst { - it.text == message.text && it.text.isNotEmpty() && it.contentType.contains("text") - && it.participant != chatConfiguration.customerName && !it.isRead - } - if (index != -1) { - val messageId = messagesList[index].messageID ?: return - val content = "{\"messageId\":\"$messageId\"}" - sendEvent(content,ContentType.MESSAGE_READ) - messagesList[index] = messagesList[index].copy(isRead = true) - _messages.postValue(messagesList) // Safely post the updated list to the LiveData - } - } - - fun endChat(){ - createParticipantConnectionResult.value?.connectionCredentials?.let { credentials -> - viewModelScope.launch { - val result = chatRepository.disconnectParticipant(credentials.connectionToken) - result.onSuccess { - // Handle success - update UI or state as needed - _webSocketUrl.value = null - }.onFailure { exception -> - // Handle failure - update UI or state, log error, etc. - Log.e("ChatViewModel", "Error sending message: ${exception.message}") - } - } - } - clearParticipantToken() - } - - fun clearErrorMessage() { - _errorMessage.value = null - } - -} diff --git a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/ChatComponents.kt b/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/ChatComponents.kt deleted file mode 100644 index 7c56b70..0000000 --- a/mobileChatExamples/androidChatExample/app/src/main/java/com/blitz/androidchatexample/views/ChatComponents.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.blitz.androidchatexample.views - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -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.blitz.androidchatexample.models.ListPickerContent -import com.blitz.androidchatexample.models.Message -import com.blitz.androidchatexample.models.MessageType -import com.blitz.androidchatexample.models.PlainTextContent -import com.blitz.androidchatexample.models.QuickReplyContent -import com.blitz.androidchatexample.utils.CommonUtils.Companion.MarkdownText - -@Composable -fun ChatMessageView(message: Message) { - when (message.messageType) { - MessageType.SENDER -> SenderChatBubble(message) - MessageType.RECEIVER -> { - if (message.text == "...") { - TypingIndicator() - } else { - when (val content = message.content) { - is PlainTextContent -> ReceiverChatBubble(message) - is QuickReplyContent -> QuickReplyContentView(message,content) - is ListPickerContent -> ListPickerContentView(message, content) - else -> Text(text = "Unsupported message type") - } - } - } - MessageType.COMMON -> CommonChatBubble(message) - } -} - -@Composable -fun SenderChatBubble(message: Message) { - Column( - horizontalAlignment = Alignment.End, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - message.participant?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - ) - } - Surface( - color = Color(0xFF4D74DA), - shape = RoundedCornerShape(10.dp), - modifier = Modifier.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.75f) - ) { - Column(modifier = Modifier.padding(10.dp)) { - MarkdownText( - text = message.text, - color = Color.White - ) - message.timeStamp?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = Color(0xFFB0BEC5), - modifier = Modifier.align(Alignment.End) - ) - } - } - } - message.status?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = Color.Gray - ) - } - } -} - -@Composable -fun ReceiverChatBubble(message: Message) { - Column( - horizontalAlignment = Alignment.Start, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) { - message.participant?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .padding(bottom = 4.dp) - ) - } - Surface( - color = Color(0xFF8BC34A), - shape = RoundedCornerShape(10.dp), - modifier = Modifier.widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.75f) - ) { - Column(modifier = Modifier.padding(10.dp)) { - MarkdownText( - text = message.text, - color = Color.White - ) - message.timeStamp?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = Color.White, - modifier = Modifier.align(Alignment.End).alpha(0.7f) - ) - } - } - } - } -} - -@Composable -fun CommonChatBubble(message: Message) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = message.text, - color = Color.Gray, - textAlign = TextAlign.Center, - modifier = Modifier - .widthIn(max = LocalConfiguration.current.screenWidthDp.dp * 0.75f) - ) - } -} - diff --git a/mobileChatExamples/androidChatExample/app/src/main/res/drawable/placeholder.png b/mobileChatExamples/androidChatExample/app/src/main/res/drawable/placeholder.png deleted file mode 100644 index 6c6877a..0000000 Binary files a/mobileChatExamples/androidChatExample/app/src/main/res/drawable/placeholder.png and /dev/null differ diff --git a/mobileChatExamples/androidChatExample/app/src/main/res/values/strings.xml b/mobileChatExamples/androidChatExample/app/src/main/res/values/strings.xml index 6a8ac08..e8921d5 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/res/values/strings.xml +++ b/mobileChatExamples/androidChatExample/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - AndroidChatExample + Android connect Chat SDK \ No newline at end of file diff --git a/mobileChatExamples/androidChatExample/app/src/main/res/values/themes.xml b/mobileChatExamples/androidChatExample/app/src/main/res/values/themes.xml index 16fc96e..befd5f8 100644 --- a/mobileChatExamples/androidChatExample/app/src/main/res/values/themes.xml +++ b/mobileChatExamples/androidChatExample/app/src/main/res/values/themes.xml @@ -1,5 +1,5 @@ -