Skip to content

Fixes the bug where chat was getting stuck while retrieving previous session's transcript #40

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ suspend fun sendMessageReceipt(transcriptItem: TranscriptItem, receiptType: Mess
--------------------

#### `ChatSession.getTranscript`

Retrieves the chat transcript.

```
Expand Down Expand Up @@ -279,6 +280,20 @@ suspend fun getTranscript(
* Type: `StartPosition?`. See [StartPosition](https://docs.aws.amazon.com/connect/latest/APIReference/API_connect-participant_StartPosition.html)
* Return result type: [TranscriptResponse](#transcriptresponse)

Upon establishing a connection, the SDK automatically calls `getTranscript` to retrieve messages
sent while the app was in the background, offline, or in similar disconnected states. Users can also
call `getTranscript` as they need, for example - pull to fetch previous message etc.

Here's how `getTranscript` works in SDK:

* **Automatic Transcript Management:** The SDK automatically manages the received transcript.
New messages are delivered through the `onTranscriptUpdated` and `onMessageReceived` callbacks.
* **Pagination:** The response includes a `nextToken` that you can use to fetch additional messages
if the transcript is large.

For more information on how getTranscript API works, please
visit [GetTranscript](https://docs.aws.amazon.com/connect/latest/APIReference/API_connect-participant_GetTranscript.html)

--------------------

#### `ChatSession.sendAttachment`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,14 +307,14 @@ fun ChatView(viewModel: ChatViewModel, activity: Activity) {

val onRefresh: () -> Unit = {
isRefreshing = true
viewModel.fetchTranscript { success ->
viewModel.fetchTranscript(onCompletion = { 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
Expand Down Expand Up @@ -369,7 +369,6 @@ fun ChatView(viewModel: ChatViewModel, activity: Activity) {
LaunchedEffect(key1 = message, key2 = index) {
if (message.contentType == ContentType.ENDED.type) {
isChatEnded = true
viewModel.clearParticipantToken()
} else {
isChatEnded = false
}
Expand Down Expand Up @@ -445,8 +444,18 @@ fun ChatMessage(
@Composable
fun ParticipantTokenSection(activity: Activity, viewModel: ChatViewModel) {
val participantToken by viewModel.liveParticipantToken.observeAsState()
val contactId by viewModel.liveContactId.observeAsState()

Column(modifier = Modifier.padding(16.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ data class StartChatRequest(
@SerializedName("InstanceId") val connectInstanceId: String,
@SerializedName("ContactFlowId") val contactFlowId: String,
@SerializedName("ParticipantDetails") val participantDetails: ParticipantDetails,
@SerializedName("PersistentChat") val persistentChat: PersistentChat? = null,
@SerializedName("SupportedMessagingContentTypes") val supportedMessagingContentTypes: List<String> = listOf("text/plain", "text/markdown")
)

data class ParticipantDetails(
@SerializedName("DisplayName") val displayName: String
)

data class PersistentChat(
@SerializedName("SourceContactId") val sourceContactId: String,
@SerializedName("RehydrationType") val rehydrationType: String
)
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ 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.PersistentChat
import com.amazon.connect.chat.androidchatexample.models.StartChatRequest
import com.amazon.connect.chat.androidchatexample.models.StartChatResponse
import com.amazon.connect.chat.androidchatexample.network.Resource
Expand Down Expand Up @@ -69,6 +70,10 @@ class ChatViewModel @Inject constructor(
private val _liveParticipantToken = MutableLiveData<String?>(sharedPreferences.getString("participantToken", null))
val liveParticipantToken: LiveData<String?> = _liveParticipantToken

// LiveData to hold participant token from shared preferences
private val _liveContactId = MutableLiveData<String?>(sharedPreferences.getString("contactId", null))
val liveContactId: LiveData<String?> = _liveContactId

// Property to get or set participant token in shared preferences
private var participantToken: String?
get() = liveParticipantToken.value
Expand All @@ -77,12 +82,25 @@ class ChatViewModel @Inject constructor(
_liveParticipantToken.value = value // Update LiveData with new token
}

private var contactId: String?
get() = liveContactId.value
set(value) {
sharedPreferences.edit().putString("contactId", value).apply()
_liveContactId.value = value // Update LiveData with new contactId
}

// Clear participant token from shared preferences
fun clearParticipantToken() {
sharedPreferences.edit().remove("participantToken").apply()
_liveParticipantToken.value = null
}

// Clear contact id from shared preferences
fun clearContactId() {
sharedPreferences.edit().remove("contactId").apply()
_liveContactId.value = null
}

// Initialize ViewModel (add additional initialization logic if needed)
init {
// Initialization logic can be added here if necessary
Expand Down Expand Up @@ -145,29 +163,34 @@ class ChatViewModel @Inject constructor(
_isLoading.value = true
messages = mutableStateListOf() // Clear existing messages

// Check if participant token exists for reconnecting
participantToken?.let {
val chatDetails = ChatDetails(participantToken = it)
if (participantToken != null) {
val chatDetails = ChatDetails(participantToken = participantToken!!)
createParticipantConnection(chatDetails)
} ?: run {
startChat() // Start a fresh chat if no token is found
} else if (contactId != null) {
startChat(contactId)
} else {
startChat(null) // Start a fresh chat if no tokens are present
}

}
}

// Start a new chat session by sending a StartChatRequest to the repository
private fun startChat() {
private fun startChat(sourceContactId: String? = null) {
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,
persistentChat = persistentChat,
participantDetails = participantDetails
)
when (val response = chatRepository.startChat(endpoint = chatConfiguration.startChatEndpoint,startChatRequest = request)) {
is Resource.Success -> {
response.data?.data?.startChatResult?.let { result ->
this@ChatViewModel.contactId = result.contactId
this@ChatViewModel.participantToken = result.participantToken
handleStartChatResponse(result)
} ?: run {
Expand Down Expand Up @@ -263,11 +286,25 @@ class ChatViewModel @Inject constructor(


// Fetch the chat transcript
fun fetchTranscript(onCompletion: (Boolean) -> Unit) {
fun fetchTranscript(onCompletion: (Boolean) -> Unit, nextToken: String? = null) {
viewModelScope.launch {
chatSession.getTranscript(ScanDirection.BACKWARD, SortKey.DESCENDING, 30, null, messages?.get(0)?.id).onSuccess {
chatSession.getTranscript(
ScanDirection.BACKWARD,
SortKey.DESCENDING,
15,
nextToken,
null
).onSuccess { response ->
Log.d("ChatViewModel", "Transcript fetched successfully")
onCompletion(true)

// Check for nextToken
if (!response.nextToken.isNullOrEmpty()) {
// Call fetchTranscript again with the nextToken
fetchTranscript(onCompletion, response.nextToken)
} else {
// No more pages to fetch, call onCompletion
onCompletion(true)
}
}.onFailure {
Log.e("ChatViewModel", "Error fetching transcript: ${it.message}")
onCompletion(false)
Expand Down
88 changes: 43 additions & 45 deletions chat-sdk/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -63,46 +63,46 @@ android {
}

dependencies {
implementation(libs.androidxCoreKtx)
implementation(libs.androidxLifecycleRuntimeKtx)
implementation(libs.androidxActivityCompose)
implementation(platform(libs.composeBom))
implementation(libs.composeUi)
implementation(libs.composeUiGraphics)
implementation(libs.composeUiToolingPreview)
implementation(libs.material3)
implementation(libs.runtimeLivedata)
api(libs.androidxCoreKtx)
api(libs.androidxLifecycleRuntimeKtx)
api(libs.androidxActivityCompose)
api(platform(libs.composeBom))
api(libs.composeUi)
api(libs.composeUiGraphics)
api(libs.composeUiToolingPreview)
api(libs.material3)
api(libs.runtimeLivedata)

// Lifecycle livedata
implementation(libs.lifecycleLivedataKtx)
implementation(libs.lifecycleViewmodelKtx)
implementation(libs.coroutinesAndroid)
api(libs.lifecycleLivedataKtx)
api(libs.lifecycleViewmodelKtx)
api(libs.coroutinesAndroid)

// Retrofit
implementation(libs.retrofit)
implementation(libs.converterGson)
implementation(libs.okhttp)
implementation(libs.loggingInterceptor)
implementation(libs.otto)
implementation(libs.adapterRxjava2)
api(libs.retrofit)
api(libs.converterGson)
api(libs.okhttp)
api(libs.loggingInterceptor)
api(libs.otto)
api(libs.adapterRxjava2)

//Hilt
implementation(libs.hiltAndroid)
implementation(libs.hiltNavigationCompose)
implementation(libs.lifecycleProcess)
api(libs.hiltAndroid)
api(libs.hiltNavigationCompose)
api(libs.lifecycleProcess)
kapt(libs.hiltCompiler)
implementation(libs.navigationCompose)
api(libs.navigationCompose)
kapt(libs.hiltAndroidCompiler)

// AWS
implementation(libs.awsSdkCore)
implementation(libs.awsSdkConnectParticipant)
api(libs.awsSdkCore)
api(libs.awsSdkConnectParticipant)

// Serialization
implementation(libs.serializationJson)
api(libs.serializationJson)

// Image loading
implementation(libs.coilCompose)
api(libs.coilCompose)

// Testing
// Mockito for mocking
Expand Down Expand Up @@ -133,25 +133,23 @@ tasks.withType<AbstractPublishToMaven>().configureEach {
// Can be used in example app like below
// Keeping group Id different for local testing purpose
// implementation("com.amazon.connect.chat.sdk:connect-chat-sdk:1.0.0")
publishing {
publications {
// Create a MavenPublication for the release build type
create<MavenPublication>("release") {
afterEvaluate {
artifact(tasks.getByName("bundleReleaseAar"))
}
groupId = "com.amazon.connect.chat.sdk"
artifactId = "connect-chat-sdk"
version = "1.0.0"


}
}
// Define the repository where the artifact will be published
repositories {
mavenLocal()
}
}
//publishing {
// publications {
// // Create a MavenPublication for the release build type
// create<MavenPublication>("release") {
// afterEvaluate {
// artifact(tasks.getByName("bundleReleaseAar"))
// }
// groupId = "com.amazon.connect.chat.sdk"
// artifactId = "connect-chat-sdk"
// version = "1.0.2"
// }
// }
// // Define the repository where the artifact will be published
// repositories {
// mavenLocal()
// }
//}


// Test summary gradle file
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ interface WebSocketManager {
val requestNewWsUrlFlow: MutableSharedFlow<Unit>
var isReconnecting: MutableStateFlow<Boolean>
suspend fun connect(wsUrl: String, isReconnectFlow: Boolean = false)
suspend fun disconnect()
suspend fun disconnect(reason: String?)
suspend fun parseTranscriptItemFromJson(jsonString: String): TranscriptItem?
}

Expand Down Expand Up @@ -152,13 +152,14 @@ class WebSocketManagerImpl @Inject constructor(

private fun closeWebSocket(reason: String? = null) {
CoroutineScope(Dispatchers.IO).launch {
isChatActive = false;
resetHeartbeatManagers()
webSocket?.close(1000, reason)
}
}

override suspend fun disconnect() {
closeWebSocket("Disconnecting...")
override suspend fun disconnect(reason: String?) {
closeWebSocket(reason)
}

// --- WebSocket Listener ---
Expand All @@ -181,10 +182,12 @@ class WebSocketManagerImpl @Inject constructor(
}

override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.i("WebSocket", "WebSocket is closed with code: $code, reason: $reason")
handleWebSocketClosed(code, reason)
}

override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e("WebSocket", "WebSocket failure: ${t.message}")
handleWebSocketFailure(t)
}
}
Expand Down Expand Up @@ -428,9 +431,6 @@ class WebSocketManagerImpl @Inject constructor(
}

private suspend fun handleChatEnded(innerJson: JSONObject, rawData: String): TranscriptItem {
closeWebSocket("Chat Ended");
isChatActive = false;
this._eventPublisher.emit(ChatEvent.ChatEnded)
val time = innerJson.getString("AbsoluteTime")
val eventId = innerJson.getString("Id")
val event = Event(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -369,9 +369,6 @@ class ChatServiceImpl @Inject constructor(
}

_transcriptListPublisher.emit(internalTranscript)
if (ContentType.fromType(item.contentType) == ContentType.ENDED) {
clearSubscriptionsAndPublishers()
}
}
}

Expand Down Expand Up @@ -424,6 +421,8 @@ class ChatServiceImpl @Inject constructor(
awsClient.disconnectParticipantConnection(connectionDetails.connectionToken)
.getOrThrow()
SDKLogger.logger.logDebug { "Participant Disconnected" }
webSocketManager.disconnect("Customer ended the chat")
_eventPublisher.emit(ChatEvent.ChatEnded)
connectionDetailsProvider.setChatSessionState(false)
true
}.onFailure { exception ->
Expand Down
2 changes: 1 addition & 1 deletion chat-sdk/version.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
sdkVersion=1.0.1
sdkVersion=1.0.3
groupId=software.aws.connect
artifactId=amazon-connect-chat-android
Loading